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..e25556b
--- /dev/null
+++ b/src/miru_device_sdk/resources/calibrations.py
@@ -0,0 +1,249 @@
+# 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. 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
+ 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. 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
+ 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..68bfe73
--- /dev/null
+++ b/src/miru_device_sdk/types/calibration_create_response.py
@@ -0,0 +1,22 @@
+# 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):
+ 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.
+
+ 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..5c2c7f0 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,29 @@ 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`).
+ """
+
+ 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
@@ -91,6 +111,17 @@ 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.
+ """
+
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 = [