From cd66d4b8691bf2343b86405db7fc89ae0cf9bba7 Mon Sep 17 00:00:00 2001 From: stlc-bot Date: Fri, 23 Oct 2026 21:29:36 +0000 Subject: [PATCH 1/3] chore: regenerate SDK from openapi config Stainless-Generated-From: e02f0296a7692830decba091f258669afbc6f1dc --- .stats.yml | 2 +- README.md | 19 ++ api.md | 12 + src/miru_device_sdk/_client.py | 39 ++- src/miru_device_sdk/resources/__init__.py | 14 + src/miru_device_sdk/resources/calibrations.py | 251 ++++++++++++++++++ src/miru_device_sdk/types/__init__.py | 2 + .../types/calibration_create_params.py | 65 +++++ .../types/calibration_create_response.py | 15 ++ src/miru_device_sdk/types/file_rule.py | 55 +++- tests/api_resources/test_calibrations.py | 146 ++++++++++ uv.lock | 42 +-- 12 files changed, 627 insertions(+), 35 deletions(-) create mode 100644 src/miru_device_sdk/resources/calibrations.py create mode 100644 src/miru_device_sdk/types/calibration_create_params.py create mode 100644 src/miru_device_sdk/types/calibration_create_response.py create mode 100644 tests/api_resources/test_calibrations.py diff --git a/.stats.yml b/.stats.yml index 34b6007..6ecfe8d 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1 +1 @@ -configured_endpoints: 11 +configured_endpoints: 12 diff --git a/README.md b/README.md index 6d6f664..8e3d267 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,25 @@ Nested request parameters are [TypedDicts](https://docs.python.org/3/library/typ Typed requests and responses provide autocomplete and documentation within your editor. If you would like to see type errors in VS Code to help catch bugs earlier, set `python.analysis.typeCheckingMode` to `basic`. +## Nested params + +Nested parameters are dictionaries, typed using `TypedDict`, for example: + +```python +from miru_device_sdk import Miru + +client = Miru() + +calibration = client.calibrations.create( + component_slug="arm", + content={ + "data": '{\n "enable_autonomy": true,\n "enable_remote_control": true,\n "max_payload_kg": 10.0\n}\n', + "format": "json", + }, +) +print(calibration.content) +``` + ## Handling errors When the library is unable to connect to the API (for example, due to network connection problems or a timeout), a subclass of `miru_device_sdk.APIConnectionError` is raised. diff --git a/api.md b/api.md index e061608..5fc74d9 100644 --- a/api.md +++ b/api.md @@ -11,6 +11,18 @@ Methods: - client.agent.health() -> AgentHealthResponse - client.agent.version() -> AgentVersionResponse +# Calibrations + +Types: + +```python +from miru_device_sdk.types import CalibrationCreateResponse +``` + +Methods: + +- client.calibrations.create(\*\*params) -> CalibrationCreateResponse + # Deployments Types: diff --git a/src/miru_device_sdk/_client.py b/src/miru_device_sdk/_client.py index e3dced6..b9d1cd7 100644 --- a/src/miru_device_sdk/_client.py +++ b/src/miru_device_sdk/_client.py @@ -46,7 +46,7 @@ ) if TYPE_CHECKING: - from .resources import agent, device, events, releases, file_rules, deployments, git_commits + from .resources import agent, device, events, releases, file_rules, deployments, git_commits, calibrations from .resources.agent import AgentResource, AsyncAgentResource from .resources.device import DeviceResource, AsyncDeviceResource from .resources.events import EventsResource, AsyncEventsResource @@ -54,6 +54,7 @@ from .resources.file_rules import FileRulesResource, AsyncFileRulesResource from .resources.deployments import DeploymentsResource, AsyncDeploymentsResource from .resources.git_commits import GitCommitsResource, AsyncGitCommitsResource + from .resources.calibrations import CalibrationsResource, AsyncCalibrationsResource __all__ = ["Timeout", "Transport", "ProxiesTypes", "RequestOptions", "Miru", "AsyncMiru", "Client", "AsyncClient"] @@ -205,6 +206,12 @@ def agent(self) -> AgentResource: return AgentResource(self) + @cached_property + def calibrations(self) -> CalibrationsResource: + from .resources.calibrations import CalibrationsResource + + return CalibrationsResource(self) + @cached_property def deployments(self) -> DeploymentsResource: from .resources.deployments import DeploymentsResource @@ -477,6 +484,12 @@ def agent(self) -> AsyncAgentResource: return AsyncAgentResource(self) + @cached_property + def calibrations(self) -> AsyncCalibrationsResource: + from .resources.calibrations import AsyncCalibrationsResource + + return AsyncCalibrationsResource(self) + @cached_property def deployments(self) -> AsyncDeploymentsResource: from .resources.deployments import AsyncDeploymentsResource @@ -662,6 +675,12 @@ def agent(self) -> agent.AgentResourceWithRawResponse: return AgentResourceWithRawResponse(self._client.agent) + @cached_property + def calibrations(self) -> calibrations.CalibrationsResourceWithRawResponse: + from .resources.calibrations import CalibrationsResourceWithRawResponse + + return CalibrationsResourceWithRawResponse(self._client.calibrations) + @cached_property def deployments(self) -> deployments.DeploymentsResourceWithRawResponse: from .resources.deployments import DeploymentsResourceWithRawResponse @@ -711,6 +730,12 @@ def agent(self) -> agent.AsyncAgentResourceWithRawResponse: return AsyncAgentResourceWithRawResponse(self._client.agent) + @cached_property + def calibrations(self) -> calibrations.AsyncCalibrationsResourceWithRawResponse: + from .resources.calibrations import AsyncCalibrationsResourceWithRawResponse + + return AsyncCalibrationsResourceWithRawResponse(self._client.calibrations) + @cached_property def deployments(self) -> deployments.AsyncDeploymentsResourceWithRawResponse: from .resources.deployments import AsyncDeploymentsResourceWithRawResponse @@ -760,6 +785,12 @@ def agent(self) -> agent.AgentResourceWithStreamingResponse: return AgentResourceWithStreamingResponse(self._client.agent) + @cached_property + def calibrations(self) -> calibrations.CalibrationsResourceWithStreamingResponse: + from .resources.calibrations import CalibrationsResourceWithStreamingResponse + + return CalibrationsResourceWithStreamingResponse(self._client.calibrations) + @cached_property def deployments(self) -> deployments.DeploymentsResourceWithStreamingResponse: from .resources.deployments import DeploymentsResourceWithStreamingResponse @@ -809,6 +840,12 @@ def agent(self) -> agent.AsyncAgentResourceWithStreamingResponse: return AsyncAgentResourceWithStreamingResponse(self._client.agent) + @cached_property + def calibrations(self) -> calibrations.AsyncCalibrationsResourceWithStreamingResponse: + from .resources.calibrations import AsyncCalibrationsResourceWithStreamingResponse + + return AsyncCalibrationsResourceWithStreamingResponse(self._client.calibrations) + @cached_property def deployments(self) -> deployments.AsyncDeploymentsResourceWithStreamingResponse: from .resources.deployments import AsyncDeploymentsResourceWithStreamingResponse diff --git a/src/miru_device_sdk/resources/__init__.py b/src/miru_device_sdk/resources/__init__.py index 758bf0a..7d0921b 100644 --- a/src/miru_device_sdk/resources/__init__.py +++ b/src/miru_device_sdk/resources/__init__.py @@ -56,6 +56,14 @@ GitCommitsResourceWithStreamingResponse, AsyncGitCommitsResourceWithStreamingResponse, ) +from .calibrations import ( + CalibrationsResource, + AsyncCalibrationsResource, + CalibrationsResourceWithRawResponse, + AsyncCalibrationsResourceWithRawResponse, + CalibrationsResourceWithStreamingResponse, + AsyncCalibrationsResourceWithStreamingResponse, +) __all__ = [ "AgentResource", @@ -64,6 +72,12 @@ "AsyncAgentResourceWithRawResponse", "AgentResourceWithStreamingResponse", "AsyncAgentResourceWithStreamingResponse", + "CalibrationsResource", + "AsyncCalibrationsResource", + "CalibrationsResourceWithRawResponse", + "AsyncCalibrationsResourceWithRawResponse", + "CalibrationsResourceWithStreamingResponse", + "AsyncCalibrationsResourceWithStreamingResponse", "DeploymentsResource", "AsyncDeploymentsResource", "DeploymentsResourceWithRawResponse", diff --git a/src/miru_device_sdk/resources/calibrations.py b/src/miru_device_sdk/resources/calibrations.py new file mode 100644 index 0000000..369889b --- /dev/null +++ b/src/miru_device_sdk/resources/calibrations.py @@ -0,0 +1,251 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import httpx + +from ..types import calibration_create_params +from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given +from .._utils import maybe_transform, async_maybe_transform +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.calibration_create_response import CalibrationCreateResponse + +__all__ = ["CalibrationsResource", "AsyncCalibrationsResource"] + + +class CalibrationsResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> CalibrationsResourceWithRawResponse: + """ + 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-device-sdk#accessing-raw-response-data-eg-headers + """ + return CalibrationsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> CalibrationsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/mirurobotics/python-device-sdk#with_streaming_response + """ + return CalibrationsResourceWithStreamingResponse(self) + + def create( + self, + *, + component_slug: str, + content: calibration_create_params.Content, + aspect: str | Omit = omit, + role: str | Omit = omit, + serial_number: 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. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> CalibrationCreateResponse: + """Report a calibration to Miru. + + The agent checks the request, saves the + calibration on the device and returns right away, without waiting for Miru. It + uploads saved calibrations to Miru in the order received and retries until Miru + is reachable, so a calibration survives network outages and agent restarts. The + agent checks the request against Miru's rules before saving it, so Miru rarely + rejects one later; one it does reject, such as a new component once the + workspace holds 100, is set aside on the device and does not hold up later ones. + Content is at most 1 MiB and a request is at most about 2 MiB (1 MiB of content + plus JSON escaping). Each request is a new calibration, so sending the same + calibration twice records it twice. If the agent loses Miru's response to an + upload, for example to a timeout or a restart, it uploads the calibration again, + so on rare occasions Miru records one twice. + + Args: + component_slug: Slug of the component the calibrated unit belongs to. A new slug creates a + component in Miru. + + content: The calibration's content, at most 1 MiB. The agent uploads it to Miru + unchanged. + + aspect: Slug of the aspect of the unit that was calibrated, when it has more than one, + e.g. `intrinsics` vs `extrinsics`, or `pose` vs `home-offsets`. Validated like a + slug. Miru resolves it to one of the component's `aspects`, creating the aspect + the first time it is reported. + + role: Slug of the role the calibrated unit fills on the device, such as `aligner-1` or + `front-left`. Distinguishes several units of one component on a device. + Validated like a slug. Miru resolves it to one of the component's `roles`, + creating the role the first time it is reported. + + serial_number: Serial number of the physical unit that was calibrated, not of the device, such + as `M500-1234`. Free text, at most 255 characters. + + 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 + """ + return self._post( + "/calibrations", + body=maybe_transform( + { + "component_slug": component_slug, + "content": content, + "aspect": aspect, + "role": role, + "serial_number": serial_number, + }, + calibration_create_params.CalibrationCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=CalibrationCreateResponse, + ) + + +class AsyncCalibrationsResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncCalibrationsResourceWithRawResponse: + """ + 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-device-sdk#accessing-raw-response-data-eg-headers + """ + return AsyncCalibrationsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncCalibrationsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/mirurobotics/python-device-sdk#with_streaming_response + """ + return AsyncCalibrationsResourceWithStreamingResponse(self) + + async def create( + self, + *, + component_slug: str, + content: calibration_create_params.Content, + aspect: str | Omit = omit, + role: str | Omit = omit, + serial_number: 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. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> CalibrationCreateResponse: + """Report a calibration to Miru. + + The agent checks the request, saves the + calibration on the device and returns right away, without waiting for Miru. It + uploads saved calibrations to Miru in the order received and retries until Miru + is reachable, so a calibration survives network outages and agent restarts. The + agent checks the request against Miru's rules before saving it, so Miru rarely + rejects one later; one it does reject, such as a new component once the + workspace holds 100, is set aside on the device and does not hold up later ones. + Content is at most 1 MiB and a request is at most about 2 MiB (1 MiB of content + plus JSON escaping). Each request is a new calibration, so sending the same + calibration twice records it twice. If the agent loses Miru's response to an + upload, for example to a timeout or a restart, it uploads the calibration again, + so on rare occasions Miru records one twice. + + Args: + component_slug: Slug of the component the calibrated unit belongs to. A new slug creates a + component in Miru. + + content: The calibration's content, at most 1 MiB. The agent uploads it to Miru + unchanged. + + aspect: Slug of the aspect of the unit that was calibrated, when it has more than one, + e.g. `intrinsics` vs `extrinsics`, or `pose` vs `home-offsets`. Validated like a + slug. Miru resolves it to one of the component's `aspects`, creating the aspect + the first time it is reported. + + role: Slug of the role the calibrated unit fills on the device, such as `aligner-1` or + `front-left`. Distinguishes several units of one component on a device. + Validated like a slug. Miru resolves it to one of the component's `roles`, + creating the role the first time it is reported. + + serial_number: Serial number of the physical unit that was calibrated, not of the device, such + as `M500-1234`. Free text, at most 255 characters. + + 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 + """ + return await self._post( + "/calibrations", + body=await async_maybe_transform( + { + "component_slug": component_slug, + "content": content, + "aspect": aspect, + "role": role, + "serial_number": serial_number, + }, + calibration_create_params.CalibrationCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=CalibrationCreateResponse, + ) + + +class CalibrationsResourceWithRawResponse: + def __init__(self, calibrations: CalibrationsResource) -> None: + self._calibrations = calibrations + + self.create = to_raw_response_wrapper( + calibrations.create, + ) + + +class AsyncCalibrationsResourceWithRawResponse: + def __init__(self, calibrations: AsyncCalibrationsResource) -> None: + self._calibrations = calibrations + + self.create = async_to_raw_response_wrapper( + calibrations.create, + ) + + +class CalibrationsResourceWithStreamingResponse: + def __init__(self, calibrations: CalibrationsResource) -> None: + self._calibrations = calibrations + + self.create = to_streamed_response_wrapper( + calibrations.create, + ) + + +class AsyncCalibrationsResourceWithStreamingResponse: + def __init__(self, calibrations: AsyncCalibrationsResource) -> None: + self._calibrations = calibrations + + self.create = async_to_streamed_response_wrapper( + calibrations.create, + ) diff --git a/src/miru_device_sdk/types/__init__.py b/src/miru_device_sdk/types/__init__.py index 08142a2..a9b8a4e 100644 --- a/src/miru_device_sdk/types/__init__.py +++ b/src/miru_device_sdk/types/__init__.py @@ -12,3 +12,5 @@ from .agent_health_response import AgentHealthResponse as AgentHealthResponse from .agent_version_response import AgentVersionResponse as AgentVersionResponse from .device_retrieve_response import DeviceRetrieveResponse as DeviceRetrieveResponse +from .calibration_create_params import CalibrationCreateParams as CalibrationCreateParams +from .calibration_create_response import CalibrationCreateResponse as CalibrationCreateResponse diff --git a/src/miru_device_sdk/types/calibration_create_params.py b/src/miru_device_sdk/types/calibration_create_params.py new file mode 100644 index 0000000..cf1ba7e --- /dev/null +++ b/src/miru_device_sdk/types/calibration_create_params.py @@ -0,0 +1,65 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Literal, Required, TypedDict + +__all__ = ["CalibrationCreateParams", "Content"] + + +class CalibrationCreateParams(TypedDict, total=False): + component_slug: Required[str] + """Slug of the component the calibrated unit belongs to. + + A new slug creates a component in Miru. + """ + + content: Required[Content] + """The calibration's content, at most 1 MiB. + + The agent uploads it to Miru unchanged. + """ + + aspect: str + """ + Slug of the aspect of the unit that was calibrated, when it has more than one, + e.g. `intrinsics` vs `extrinsics`, or `pose` vs `home-offsets`. Validated like a + slug. Miru resolves it to one of the component's `aspects`, creating the aspect + the first time it is reported. + """ + + role: str + """ + Slug of the role the calibrated unit fills on the device, such as `aligner-1` or + `front-left`. Distinguishes several units of one component on a device. + Validated like a slug. Miru resolves it to one of the component's `roles`, + creating the role the first time it is reported. + """ + + serial_number: str + """ + Serial number of the physical unit that was calibrated, not of the device, such + as `M500-1234`. Free text, at most 255 characters. + """ + + +class Content(TypedDict, total=False): + """The calibration's content, at most 1 MiB. + + The agent uploads it to Miru unchanged. + """ + + data: Required[str] + """The content's values, serialized in `format`.""" + + format: Required[Literal["json", "yaml", "jsonc", "xml", "text"]] + """The serialization format of config content. + + A config instance is written to the device filesystem in this format. + + - `json`: standard JSON. + - `yaml`: YAML 1.2. + - `jsonc`: JSON with comments (JSON plus `//` and `/* */` comment syntax). + - `xml`: XML. + - `text`: plain, unstructured text with no specific format. + """ diff --git a/src/miru_device_sdk/types/calibration_create_response.py b/src/miru_device_sdk/types/calibration_create_response.py new file mode 100644 index 0000000..2a702eb --- /dev/null +++ b/src/miru_device_sdk/types/calibration_create_response.py @@ -0,0 +1,15 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from datetime import datetime + +from .._models import BaseModel + +__all__ = ["CalibrationCreateResponse"] + + +class CalibrationCreateResponse(BaseModel): + reported_at: datetime + """Timestamp of when the agent received the calibration. + + Miru stores it as the time the calibration was reported. + """ diff --git a/src/miru_device_sdk/types/file_rule.py b/src/miru_device_sdk/types/file_rule.py index 85f99b3..b4a9140 100644 --- a/src/miru_device_sdk/types/file_rule.py +++ b/src/miru_device_sdk/types/file_rule.py @@ -1,6 +1,6 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -from typing import Optional +from typing import List, Optional from datetime import datetime from typing_extensions import Literal @@ -11,11 +11,15 @@ class Source(BaseModel): glob: str - """A glob pattern selecting the files this rule manages. - - Must be absolute (start with `/` on Linux, or a drive letter followed by `:\\`` or - `:/` on Windows, e.g. `C:\\llogs\\**.log`), at most 1024 bytes, and must contain no - `..` segments and no empty path segments. + """ + Absolute glob of the files this rule manages (`/` on Linux, `C:\\……` or `C:/…` on + Windows; max 1024 bytes; no `.`, `..` or empty segments). A `{name}` capture + matches like `*` within one segment and must be used in `upload.bundle` or + `upload.path`. Names match `[a-z][a-z0-9_]*`, are unique, and must not reuse a + built-in path variable. A segment holds at most one capture, and the rest of + that segment must be constant text (no `*`, `?`, `[`, `\\`` or other braces). A + capture may not have `**` segments both before and after it. `{a,b}` is literal. + Devices see each capture as `*`. """ stability_window_secs: int @@ -76,13 +80,16 @@ class Upload(BaseModel): """Name of the bucket this rule uploads to.""" path: str - """The object-path template the collected file is written to. + """Object-path template for the uploaded file. - Must contain `{upload_id}` so every upload lands on a distinct object key, be at - most 1024 bytes, have balanced braces, and contain no `..` segments and no empty - path segments. The supported variables are `{device_id}`, `{device_name}`, - `{file_name}`, `{upload_id}`, `{year}`, `{month}`, `{day}`, `{hour}`, and - `{minute}`. + Contains `{upload_id}`, is at most 1024 bytes, has balanced braces, and contains + no `..` or empty segments. Variables: `{device_id}`, `{device_name}`, + `{file_name}`, `{upload_id}`, `{bundle_id}`, `{year}`, `{month}`, `{day}`, + `{hour}`, `{minute}`, `{timezone}`, plus any `source.glob` capture. + `{bundle_id}` requires `bundle`. The time variables are zero-padded and rendered + from the file's mtime (its last-modified time, from the device clock) in + `timezone`; `{timezone}` is that instant's UTC offset in ISO 8601 basic format + (`+0000`, `-0500`). """ upload_collection_id: str @@ -91,6 +98,30 @@ class Upload(BaseModel): upload_collection_name: str """The name of the upload collection the resulting uploads are grouped into.""" + bundle: Optional[List[str]] = None + """ + Names of the `source.glob` captures whose values group the rule's uploads into + bundles: uploads from the same device and upload collection that captured the + same values, in the order listed, share a bundle, across rules and over time. + Capture names don't affect matching, so rules that name their captures + differently still share a bundle when they list them in the same order. Every + name must be a `source.glob` capture, listed once, with at most 16 names. Absent + when the rule does not bundle. + """ + + timezone: Optional[Literal["utc", "device"]] = None + """ + The time zone a file rule renders its upload path's time variables (`{year}`, + `{month}`, `{day}`, `{hour}`, `{minute}` and `{timezone}`) in. + + - `utc`: Coordinated Universal Time. + - `device`: the device's local time zone, as the agent resolves it from the `TZ` + environment variable or, when `TZ` is unset, the system time zone + (`/etc/localtime` on Linux). The UTC offset is taken at the rendered instant, + so it follows daylight saving time. A device running an agent that does not + report its UTC offset renders in UTC. + """ + class FileRule(BaseModel): """ diff --git a/tests/api_resources/test_calibrations.py b/tests/api_resources/test_calibrations.py new file mode 100644 index 0000000..53bf130 --- /dev/null +++ b/tests/api_resources/test_calibrations.py @@ -0,0 +1,146 @@ +# 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_device_sdk import Miru, AsyncMiru +from miru_device_sdk.types import CalibrationCreateResponse + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestCalibrations: + 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: + calibration = client.calibrations.create( + component_slug="arm", + content={ + "data": '{\n "enable_autonomy": true,\n "enable_remote_control": true,\n "max_payload_kg": 10.0\n}\n', + "format": "json", + }, + ) + assert_matches_type(CalibrationCreateResponse, calibration, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create_with_all_params(self, client: Miru) -> None: + calibration = client.calibrations.create( + component_slug="arm", + content={ + "data": '{\n "enable_autonomy": true,\n "enable_remote_control": true,\n "max_payload_kg": 10.0\n}\n', + "format": "json", + }, + aspect="pose", + role="aligner-1", + serial_number="M500-1234", + ) + assert_matches_type(CalibrationCreateResponse, calibration, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_create(self, client: Miru) -> None: + response = client.calibrations.with_raw_response.create( + component_slug="arm", + content={ + "data": '{\n "enable_autonomy": true,\n "enable_remote_control": true,\n "max_payload_kg": 10.0\n}\n', + "format": "json", + }, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + calibration = response.parse() + assert_matches_type(CalibrationCreateResponse, calibration, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_create(self, client: Miru) -> None: + with client.calibrations.with_streaming_response.create( + component_slug="arm", + content={ + "data": '{\n "enable_autonomy": true,\n "enable_remote_control": true,\n "max_payload_kg": 10.0\n}\n', + "format": "json", + }, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + calibration = response.parse() + assert_matches_type(CalibrationCreateResponse, calibration, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncCalibrations: + 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: + calibration = await async_client.calibrations.create( + component_slug="arm", + content={ + "data": '{\n "enable_autonomy": true,\n "enable_remote_control": true,\n "max_payload_kg": 10.0\n}\n', + "format": "json", + }, + ) + assert_matches_type(CalibrationCreateResponse, calibration, 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: + calibration = await async_client.calibrations.create( + component_slug="arm", + content={ + "data": '{\n "enable_autonomy": true,\n "enable_remote_control": true,\n "max_payload_kg": 10.0\n}\n', + "format": "json", + }, + aspect="pose", + role="aligner-1", + serial_number="M500-1234", + ) + assert_matches_type(CalibrationCreateResponse, calibration, 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.calibrations.with_raw_response.create( + component_slug="arm", + content={ + "data": '{\n "enable_autonomy": true,\n "enable_remote_control": true,\n "max_payload_kg": 10.0\n}\n', + "format": "json", + }, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + calibration = await response.parse() + assert_matches_type(CalibrationCreateResponse, calibration, 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.calibrations.with_streaming_response.create( + component_slug="arm", + content={ + "data": '{\n "enable_autonomy": true,\n "enable_remote_control": true,\n "max_payload_kg": 10.0\n}\n', + "format": "json", + }, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + calibration = await response.parse() + assert_matches_type(CalibrationCreateResponse, calibration, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/uv.lock b/uv.lock index 2fbc6f6..5f53495 100644 --- a/uv.lock +++ b/uv.lock @@ -31,12 +31,12 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, { name = "aiosignal" }, - { name = "async-timeout", marker = "python_full_version < '3.11' or (extra == 'group-15-miru-device-sdk-pydantic-v1' and extra == 'group-15-miru-device-sdk-pydantic-v2')" }, + { name = "async-timeout", marker = "python_full_version < '3.11'" }, { name = "attrs" }, { name = "frozenlist" }, { name = "multidict" }, { name = "propcache" }, - { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'group-15-miru-device-sdk-pydantic-v1' and extra == 'group-15-miru-device-sdk-pydantic-v2')" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "yarl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } @@ -167,7 +167,7 @@ version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "frozenlist" }, - { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'group-15-miru-device-sdk-pydantic-v1' and extra == 'group-15-miru-device-sdk-pydantic-v2')" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } wheels = [ @@ -188,9 +188,9 @@ name = "anyio" version = "4.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.11' or (extra == 'group-15-miru-device-sdk-pydantic-v1' and extra == 'group-15-miru-device-sdk-pydantic-v2')" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'group-15-miru-device-sdk-pydantic-v1' and extra == 'group-15-miru-device-sdk-pydantic-v2')" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } wheels = [ @@ -265,7 +265,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'group-15-miru-device-sdk-pydantic-v1' and extra == 'group-15-miru-device-sdk-pydantic-v2')" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -505,14 +505,14 @@ wheels = [ [[package]] name = "miru-device-sdk" -version = "0.6.0" +version = "0.7.0" source = { editable = "." } dependencies = [ { name = "anyio" }, { name = "distro" }, { name = "httpx" }, { name = "pydantic", version = "1.10.26", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-15-miru-device-sdk-pydantic-v1'" }, - { name = "pydantic", version = "2.12.5", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-15-miru-device-sdk-pydantic-v2' or extra != 'group-15-miru-device-sdk-pydantic-v1'" }, + { name = "pydantic", version = "2.12.5", source = { registry = "https://pypi.org/simple" }, marker = "extra != 'group-15-miru-device-sdk-pydantic-v1' or extra == 'group-15-miru-device-sdk-pydantic-v2'" }, { name = "sniffio" }, { name = "typing-extensions" }, ] @@ -582,7 +582,7 @@ name = "multidict" version = "6.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11' or (extra == 'group-15-miru-device-sdk-pydantic-v1' and extra == 'group-15-miru-device-sdk-pydantic-v2')" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/80/1e/5492c365f222f907de1039b91f922b93fa4f764c713ee858d235495d8f50/multidict-6.7.0.tar.gz", hash = "sha256:c6e99d9a65ca282e578dfea819cfa9c0a62b2499d8677392e09feaf305e9e6f5", size = 101834, upload-time = "2025-10-06T14:52:30.657Z" } wheels = [ @@ -722,7 +722,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mypy-extensions" }, { name = "pathspec" }, - { name = "tomli", marker = "python_full_version < '3.11' or (extra == 'group-15-miru-device-sdk-pydantic-v1' and extra == 'group-15-miru-device-sdk-pydantic-v2')" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1e/e3/034322d5a779685218ed69286c32faa505247f1f096251ef66c8fd203b08/mypy-1.17.0.tar.gz", hash = "sha256:e5d7ccc08ba089c06e2f5629c660388ef1fee708444f1dee0b9203fa031dee03", size = 3352114, upload-time = "2025-07-14T20:34:30.181Z" } @@ -960,10 +960,10 @@ resolution-markers = [ "extra != 'group-15-miru-device-sdk-pydantic-v1' and extra != 'group-15-miru-device-sdk-pydantic-v2'", ] dependencies = [ - { name = "annotated-types", marker = "extra == 'group-15-miru-device-sdk-pydantic-v2' or extra != 'group-15-miru-device-sdk-pydantic-v1'" }, - { name = "pydantic-core", marker = "extra == 'group-15-miru-device-sdk-pydantic-v2' or extra != 'group-15-miru-device-sdk-pydantic-v1'" }, - { name = "typing-extensions", marker = "extra == 'group-15-miru-device-sdk-pydantic-v2' or extra != 'group-15-miru-device-sdk-pydantic-v1'" }, - { name = "typing-inspection", marker = "extra == 'group-15-miru-device-sdk-pydantic-v2' or extra != 'group-15-miru-device-sdk-pydantic-v1'" }, + { name = "annotated-types", marker = "extra != 'group-15-miru-device-sdk-pydantic-v1' or extra == 'group-15-miru-device-sdk-pydantic-v2'" }, + { name = "pydantic-core", marker = "extra != 'group-15-miru-device-sdk-pydantic-v1' or extra == 'group-15-miru-device-sdk-pydantic-v2'" }, + { name = "typing-extensions", marker = "extra != 'group-15-miru-device-sdk-pydantic-v1' or extra == 'group-15-miru-device-sdk-pydantic-v2'" }, + { name = "typing-inspection", marker = "extra != 'group-15-miru-device-sdk-pydantic-v1' or extra == 'group-15-miru-device-sdk-pydantic-v2'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } wheels = [ @@ -975,7 +975,7 @@ name = "pydantic-core" version = "2.41.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "extra == 'group-15-miru-device-sdk-pydantic-v2' or extra != 'group-15-miru-device-sdk-pydantic-v1'" }, + { name = "typing-extensions", marker = "extra != 'group-15-miru-device-sdk-pydantic-v1' or extra == 'group-15-miru-device-sdk-pydantic-v2'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } wheels = [ @@ -1115,13 +1115,13 @@ name = "pytest" version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32' or (extra == 'group-15-miru-device-sdk-pydantic-v1' and extra == 'group-15-miru-device-sdk-pydantic-v2')" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11' or (extra == 'group-15-miru-device-sdk-pydantic-v1' and extra == 'group-15-miru-device-sdk-pydantic-v2')" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, - { name = "tomli", marker = "python_full_version < '3.11' or (extra == 'group-15-miru-device-sdk-pydantic-v1' and extra == 'group-15-miru-device-sdk-pydantic-v2')" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ @@ -1133,9 +1133,9 @@ name = "pytest-asyncio" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "backports-asyncio-runner", marker = "python_full_version < '3.11' or (extra == 'group-15-miru-device-sdk-pydantic-v1' and extra == 'group-15-miru-device-sdk-pydantic-v2')" }, + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, { name = "pytest" }, - { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'group-15-miru-device-sdk-pydantic-v1' and extra == 'group-15-miru-device-sdk-pydantic-v2')" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } wheels = [ @@ -1368,7 +1368,7 @@ name = "typing-inspection" version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "extra == 'group-15-miru-device-sdk-pydantic-v2' or extra != 'group-15-miru-device-sdk-pydantic-v1'" }, + { name = "typing-extensions", marker = "extra != 'group-15-miru-device-sdk-pydantic-v1' or extra == 'group-15-miru-device-sdk-pydantic-v2'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ From c78bed83bb90192ffc44cea5abd840bbe2d87532 Mon Sep 17 00:00:00 2001 From: stlc-bot Date: Sat, 24 Oct 2026 02:05:46 +0000 Subject: [PATCH 2/3] feat(file-rules): require upload.timezone in file rule responses (#347) Stainless-Generated-From: 96599133e0c6252450d2a62eecf5085b11011929 --- src/miru_device_sdk/types/file_rule.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/miru_device_sdk/types/file_rule.py b/src/miru_device_sdk/types/file_rule.py index b4a9140..5c2c7f0 100644 --- a/src/miru_device_sdk/types/file_rule.py +++ b/src/miru_device_sdk/types/file_rule.py @@ -92,6 +92,19 @@ class Upload(BaseModel): (`+0000`, `-0500`). """ + timezone: Literal["utc", "device"] + """ + The time zone a file rule renders its upload path's time variables (`{year}`, + `{month}`, `{day}`, `{hour}`, `{minute}` and `{timezone}`) in. + + - `utc`: Coordinated Universal Time. + - `device`: the device's local time zone, as the agent resolves it from the `TZ` + environment variable or, when `TZ` is unset, the system time zone + (`/etc/localtime` on Linux). The UTC offset is taken at the rendered instant, + so it follows daylight saving time. A device running an agent that does not + report its UTC offset renders in UTC. + """ + upload_collection_id: str """ID of the upload collection the resulting uploads are grouped into.""" @@ -109,19 +122,6 @@ class Upload(BaseModel): when the rule does not bundle. """ - timezone: Optional[Literal["utc", "device"]] = None - """ - The time zone a file rule renders its upload path's time variables (`{year}`, - `{month}`, `{day}`, `{hour}`, `{minute}` and `{timezone}`) in. - - - `utc`: Coordinated Universal Time. - - `device`: the device's local time zone, as the agent resolves it from the `TZ` - environment variable or, when `TZ` is unset, the system time zone - (`/etc/localtime` on Linux). The UTC offset is taken at the rendered instant, - so it follows daylight saving time. A device running an agent that does not - report its UTC offset renders in UTC. - """ - class FileRule(BaseModel): """ From 9124d7568a781f6755220dfe23be590548d8eb42 Mon Sep 17 00:00:00 2001 From: stlc-bot Date: Sat, 24 Oct 2026 17:58:47 +0000 Subject: [PATCH 3/3] feat(calibrations): report_id on calibration reports (#352) Stainless-Generated-From: b86753ab7d628d7d3e6dcd5241ed5cd4b77cbbfd --- src/miru_device_sdk/resources/calibrations.py | 10 ++++------ .../types/calibration_create_response.py | 7 +++++++ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/miru_device_sdk/resources/calibrations.py b/src/miru_device_sdk/resources/calibrations.py index 369889b..e25556b 100644 --- a/src/miru_device_sdk/resources/calibrations.py +++ b/src/miru_device_sdk/resources/calibrations.py @@ -67,9 +67,8 @@ def create( workspace holds 100, is set aside on the device and does not hold up later ones. Content is at most 1 MiB and a request is at most about 2 MiB (1 MiB of content plus JSON escaping). Each request is a new calibration, so sending the same - calibration twice records it twice. If the agent loses Miru's response to an - upload, for example to a timeout or a restart, it uploads the calibration again, - so on rare occasions Miru records one twice. + calibration twice records it twice. The agent sends the report's `report_id` + with every upload, so a retried upload does not record the calibration twice. Args: component_slug: Slug of the component the calibrated unit belongs to. A new slug creates a @@ -164,9 +163,8 @@ async def create( workspace holds 100, is set aside on the device and does not hold up later ones. Content is at most 1 MiB and a request is at most about 2 MiB (1 MiB of content plus JSON escaping). Each request is a new calibration, so sending the same - calibration twice records it twice. If the agent loses Miru's response to an - upload, for example to a timeout or a restart, it uploads the calibration again, - so on rare occasions Miru records one twice. + calibration twice records it twice. The agent sends the report's `report_id` + with every upload, so a retried upload does not record the calibration twice. Args: component_slug: Slug of the component the calibrated unit belongs to. A new slug creates a diff --git a/src/miru_device_sdk/types/calibration_create_response.py b/src/miru_device_sdk/types/calibration_create_response.py index 2a702eb..68bfe73 100644 --- a/src/miru_device_sdk/types/calibration_create_response.py +++ b/src/miru_device_sdk/types/calibration_create_response.py @@ -8,6 +8,13 @@ class CalibrationCreateResponse(BaseModel): + report_id: str + """ID the agent gave this report. + + The agent sends it to Miru with the calibration, and Miru stores it as the + calibration's `report_id`. + """ + reported_at: datetime """Timestamp of when the agent received the calibration.