diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e5dc504..9b49e67 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -19,7 +19,7 @@ jobs:
runs-on: ${{ github.repository == 'stainless-sdks/droidrun-cloud-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }}
if: github.event_name == 'push' || github.event.pull_request.head.repo.fork
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Install Rye
run: |
@@ -44,7 +44,7 @@ jobs:
id-token: write
runs-on: ${{ github.repository == 'stainless-sdks/droidrun-cloud-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Install Rye
run: |
@@ -81,7 +81,7 @@ jobs:
runs-on: ${{ github.repository == 'stainless-sdks/droidrun-cloud-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }}
if: github.event_name == 'push' || github.event.pull_request.head.repo.fork
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Install Rye
run: |
diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml
index 33be2b4..965bfe5 100644
--- a/.github/workflows/publish-pypi.yml
+++ b/.github/workflows/publish-pypi.yml
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Install Rye
run: |
diff --git a/.github/workflows/release-doctor.yml b/.github/workflows/release-doctor.yml
index 66b9372..9f5c3c4 100644
--- a/.github/workflows/release-doctor.yml
+++ b/.github/workflows/release-doctor.yml
@@ -12,7 +12,7 @@ jobs:
if: github.repository == 'droidrun/mobilerun-sdk-python' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release-please') || github.head_ref == 'next')
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Check release environment
run: |
diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index 65f558e..656a2ef 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,3 +1,3 @@
{
- ".": "2.0.0"
+ ".": "2.1.0"
}
\ No newline at end of file
diff --git a/.stats.yml b/.stats.yml
index 7a32e45..dfa5d3a 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
-configured_endpoints: 49
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/droidrun%2Fdroidrun-cloud-f80ecf1ef8ff0bf85d545b660eeef8677c62d571dc692b47fc044fc82378d330.yml
-openapi_spec_hash: 51d80499a2291f8d223276f759392574
-config_hash: 12fc3bd7f141a7f09f5ad38cfa42ba3d
+configured_endpoints: 50
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/droidrun%2Fdroidrun-cloud-e8819bf716fbe8dea79c4ba0056ac76ab711d1aa647205fb45f39e4c7f90e154.yml
+openapi_spec_hash: c9f6063a93c55a53c90775686976ff0e
+config_hash: e86cf4289cfec730125313d2222d09e8
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ffa3740..b710ef5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,22 @@
# Changelog
+## 2.1.0 (2026-01-17)
+
+Full Changelog: [v2.0.0...v2.1.0](https://github.com/droidrun/mobilerun-sdk-python/compare/v2.0.0...v2.1.0)
+
+### Features
+
+* **api:** api update ([e42aa53](https://github.com/droidrun/mobilerun-sdk-python/commit/e42aa5309759f931ceab3eee40792e559f21d3a3))
+* **api:** api update ([866c20e](https://github.com/droidrun/mobilerun-sdk-python/commit/866c20ec6ebd177c861777fee900259186347ef0))
+* **api:** api update ([416142e](https://github.com/droidrun/mobilerun-sdk-python/commit/416142eb345a07aefea8404a97231ed0acd74678))
+* **api:** expose device count endpoint ([ab1191d](https://github.com/droidrun/mobilerun-sdk-python/commit/ab1191d28943441844e81c6c1189aebc34f54980))
+* **client:** add support for binary request streaming ([c6668af](https://github.com/droidrun/mobilerun-sdk-python/commit/c6668af5dbd83d7ab1dd1fe4f68253422e055e73))
+
+
+### Chores
+
+* **internal:** update `actions/checkout` version ([75af377](https://github.com/droidrun/mobilerun-sdk-python/commit/75af377b29fca57b52843186af3e9775bfc78c13))
+
## 2.0.0 (2026-01-12)
Full Changelog: [v0.1.0...v2.0.0](https://github.com/droidrun/mobilerun-sdk-python/compare/v0.1.0...v2.0.0)
diff --git a/api.md b/api.md
index 20d08ea..0d58a6a 100644
--- a/api.md
+++ b/api.md
@@ -59,7 +59,7 @@ Methods:
Types:
```python
-from mobilerun.types import Device, DeviceListResponse
+from mobilerun.types import Device, DeviceListResponse, DeviceCountResponse
```
Methods:
@@ -67,6 +67,7 @@ Methods:
- client.devices.create(\*\*params) -> Device
- client.devices.retrieve(device_id) -> Device
- client.devices.list(\*\*params) -> DeviceListResponse
+- client.devices.count() -> DeviceCountResponse
- client.devices.terminate(device_id) -> None
- client.devices.wait_ready(device_id) -> Device
diff --git a/pyproject.toml b/pyproject.toml
index 4874197..2ca5b27 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "mobilerun-sdk"
-version = "2.0.0"
+version = "2.1.0"
description = "The official Python library for the mobilerun API"
dynamic = ["readme"]
license = "Apache-2.0"
diff --git a/src/mobilerun/_base_client.py b/src/mobilerun/_base_client.py
index f24e0dc..5177829 100644
--- a/src/mobilerun/_base_client.py
+++ b/src/mobilerun/_base_client.py
@@ -9,6 +9,7 @@
import inspect
import logging
import platform
+import warnings
import email.utils
from types import TracebackType
from random import random
@@ -51,9 +52,11 @@
ResponseT,
AnyMapping,
PostParser,
+ BinaryTypes,
RequestFiles,
HttpxSendArgs,
RequestOptions,
+ AsyncBinaryTypes,
HttpxRequestFiles,
ModelBuilderProtocol,
not_given,
@@ -477,8 +480,19 @@ def _build_request(
retries_taken: int = 0,
) -> httpx.Request:
if log.isEnabledFor(logging.DEBUG):
- log.debug("Request options: %s", model_dump(options, exclude_unset=True))
-
+ log.debug(
+ "Request options: %s",
+ model_dump(
+ options,
+ exclude_unset=True,
+ # Pydantic v1 can't dump every type we support in content, so we exclude it for now.
+ exclude={
+ "content",
+ }
+ if PYDANTIC_V1
+ else {},
+ ),
+ )
kwargs: dict[str, Any] = {}
json_data = options.json_data
@@ -532,7 +546,13 @@ def _build_request(
is_body_allowed = options.method.lower() != "get"
if is_body_allowed:
- if isinstance(json_data, bytes):
+ if options.content is not None and json_data is not None:
+ raise TypeError("Passing both `content` and `json_data` is not supported")
+ if options.content is not None and files is not None:
+ raise TypeError("Passing both `content` and `files` is not supported")
+ if options.content is not None:
+ kwargs["content"] = options.content
+ elif isinstance(json_data, bytes):
kwargs["content"] = json_data
else:
kwargs["json"] = json_data if is_given(json_data) else None
@@ -1194,6 +1214,7 @@ def post(
*,
cast_to: Type[ResponseT],
body: Body | None = None,
+ content: BinaryTypes | None = None,
options: RequestOptions = {},
files: RequestFiles | None = None,
stream: Literal[False] = False,
@@ -1206,6 +1227,7 @@ def post(
*,
cast_to: Type[ResponseT],
body: Body | None = None,
+ content: BinaryTypes | None = None,
options: RequestOptions = {},
files: RequestFiles | None = None,
stream: Literal[True],
@@ -1219,6 +1241,7 @@ def post(
*,
cast_to: Type[ResponseT],
body: Body | None = None,
+ content: BinaryTypes | None = None,
options: RequestOptions = {},
files: RequestFiles | None = None,
stream: bool,
@@ -1231,13 +1254,25 @@ def post(
*,
cast_to: Type[ResponseT],
body: Body | None = None,
+ content: BinaryTypes | None = None,
options: RequestOptions = {},
files: RequestFiles | None = None,
stream: bool = False,
stream_cls: type[_StreamT] | None = None,
) -> ResponseT | _StreamT:
+ if body is not None and content is not None:
+ raise TypeError("Passing both `body` and `content` is not supported")
+ if files is not None and content is not None:
+ raise TypeError("Passing both `files` and `content` is not supported")
+ if isinstance(body, bytes):
+ warnings.warn(
+ "Passing raw bytes as `body` is deprecated and will be removed in a future version. "
+ "Please pass raw bytes via the `content` parameter instead.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
opts = FinalRequestOptions.construct(
- method="post", url=path, json_data=body, files=to_httpx_files(files), **options
+ method="post", url=path, json_data=body, content=content, files=to_httpx_files(files), **options
)
return cast(ResponseT, self.request(cast_to, opts, stream=stream, stream_cls=stream_cls))
@@ -1247,11 +1282,23 @@ def patch(
*,
cast_to: Type[ResponseT],
body: Body | None = None,
+ content: BinaryTypes | None = None,
files: RequestFiles | None = None,
options: RequestOptions = {},
) -> ResponseT:
+ if body is not None and content is not None:
+ raise TypeError("Passing both `body` and `content` is not supported")
+ if files is not None and content is not None:
+ raise TypeError("Passing both `files` and `content` is not supported")
+ if isinstance(body, bytes):
+ warnings.warn(
+ "Passing raw bytes as `body` is deprecated and will be removed in a future version. "
+ "Please pass raw bytes via the `content` parameter instead.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
opts = FinalRequestOptions.construct(
- method="patch", url=path, json_data=body, files=to_httpx_files(files), **options
+ method="patch", url=path, json_data=body, content=content, files=to_httpx_files(files), **options
)
return self.request(cast_to, opts)
@@ -1261,11 +1308,23 @@ def put(
*,
cast_to: Type[ResponseT],
body: Body | None = None,
+ content: BinaryTypes | None = None,
files: RequestFiles | None = None,
options: RequestOptions = {},
) -> ResponseT:
+ if body is not None and content is not None:
+ raise TypeError("Passing both `body` and `content` is not supported")
+ if files is not None and content is not None:
+ raise TypeError("Passing both `files` and `content` is not supported")
+ if isinstance(body, bytes):
+ warnings.warn(
+ "Passing raw bytes as `body` is deprecated and will be removed in a future version. "
+ "Please pass raw bytes via the `content` parameter instead.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
opts = FinalRequestOptions.construct(
- method="put", url=path, json_data=body, files=to_httpx_files(files), **options
+ method="put", url=path, json_data=body, content=content, files=to_httpx_files(files), **options
)
return self.request(cast_to, opts)
@@ -1275,9 +1334,19 @@ def delete(
*,
cast_to: Type[ResponseT],
body: Body | None = None,
+ content: BinaryTypes | None = None,
options: RequestOptions = {},
) -> ResponseT:
- opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, **options)
+ if body is not None and content is not None:
+ raise TypeError("Passing both `body` and `content` is not supported")
+ if isinstance(body, bytes):
+ warnings.warn(
+ "Passing raw bytes as `body` is deprecated and will be removed in a future version. "
+ "Please pass raw bytes via the `content` parameter instead.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+ opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, content=content, **options)
return self.request(cast_to, opts)
def get_api_list(
@@ -1717,6 +1786,7 @@ async def post(
*,
cast_to: Type[ResponseT],
body: Body | None = None,
+ content: AsyncBinaryTypes | None = None,
files: RequestFiles | None = None,
options: RequestOptions = {},
stream: Literal[False] = False,
@@ -1729,6 +1799,7 @@ async def post(
*,
cast_to: Type[ResponseT],
body: Body | None = None,
+ content: AsyncBinaryTypes | None = None,
files: RequestFiles | None = None,
options: RequestOptions = {},
stream: Literal[True],
@@ -1742,6 +1813,7 @@ async def post(
*,
cast_to: Type[ResponseT],
body: Body | None = None,
+ content: AsyncBinaryTypes | None = None,
files: RequestFiles | None = None,
options: RequestOptions = {},
stream: bool,
@@ -1754,13 +1826,25 @@ async def post(
*,
cast_to: Type[ResponseT],
body: Body | None = None,
+ content: AsyncBinaryTypes | None = None,
files: RequestFiles | None = None,
options: RequestOptions = {},
stream: bool = False,
stream_cls: type[_AsyncStreamT] | None = None,
) -> ResponseT | _AsyncStreamT:
+ if body is not None and content is not None:
+ raise TypeError("Passing both `body` and `content` is not supported")
+ if files is not None and content is not None:
+ raise TypeError("Passing both `files` and `content` is not supported")
+ if isinstance(body, bytes):
+ warnings.warn(
+ "Passing raw bytes as `body` is deprecated and will be removed in a future version. "
+ "Please pass raw bytes via the `content` parameter instead.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
opts = FinalRequestOptions.construct(
- method="post", url=path, json_data=body, files=await async_to_httpx_files(files), **options
+ method="post", url=path, json_data=body, content=content, files=await async_to_httpx_files(files), **options
)
return await self.request(cast_to, opts, stream=stream, stream_cls=stream_cls)
@@ -1770,11 +1854,28 @@ async def patch(
*,
cast_to: Type[ResponseT],
body: Body | None = None,
+ content: AsyncBinaryTypes | None = None,
files: RequestFiles | None = None,
options: RequestOptions = {},
) -> ResponseT:
+ if body is not None and content is not None:
+ raise TypeError("Passing both `body` and `content` is not supported")
+ if files is not None and content is not None:
+ raise TypeError("Passing both `files` and `content` is not supported")
+ if isinstance(body, bytes):
+ warnings.warn(
+ "Passing raw bytes as `body` is deprecated and will be removed in a future version. "
+ "Please pass raw bytes via the `content` parameter instead.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
opts = FinalRequestOptions.construct(
- method="patch", url=path, json_data=body, files=await async_to_httpx_files(files), **options
+ method="patch",
+ url=path,
+ json_data=body,
+ content=content,
+ files=await async_to_httpx_files(files),
+ **options,
)
return await self.request(cast_to, opts)
@@ -1784,11 +1885,23 @@ async def put(
*,
cast_to: Type[ResponseT],
body: Body | None = None,
+ content: AsyncBinaryTypes | None = None,
files: RequestFiles | None = None,
options: RequestOptions = {},
) -> ResponseT:
+ if body is not None and content is not None:
+ raise TypeError("Passing both `body` and `content` is not supported")
+ if files is not None and content is not None:
+ raise TypeError("Passing both `files` and `content` is not supported")
+ if isinstance(body, bytes):
+ warnings.warn(
+ "Passing raw bytes as `body` is deprecated and will be removed in a future version. "
+ "Please pass raw bytes via the `content` parameter instead.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
opts = FinalRequestOptions.construct(
- method="put", url=path, json_data=body, files=await async_to_httpx_files(files), **options
+ method="put", url=path, json_data=body, content=content, files=await async_to_httpx_files(files), **options
)
return await self.request(cast_to, opts)
@@ -1798,9 +1911,19 @@ async def delete(
*,
cast_to: Type[ResponseT],
body: Body | None = None,
+ content: AsyncBinaryTypes | None = None,
options: RequestOptions = {},
) -> ResponseT:
- opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, **options)
+ if body is not None and content is not None:
+ raise TypeError("Passing both `body` and `content` is not supported")
+ if isinstance(body, bytes):
+ warnings.warn(
+ "Passing raw bytes as `body` is deprecated and will be removed in a future version. "
+ "Please pass raw bytes via the `content` parameter instead.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+ opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, content=content, **options)
return await self.request(cast_to, opts)
def get_api_list(
diff --git a/src/mobilerun/_models.py b/src/mobilerun/_models.py
index ca9500b..29070e0 100644
--- a/src/mobilerun/_models.py
+++ b/src/mobilerun/_models.py
@@ -3,7 +3,20 @@
import os
import inspect
import weakref
-from typing import TYPE_CHECKING, Any, Type, Union, Generic, TypeVar, Callable, Optional, cast
+from typing import (
+ IO,
+ TYPE_CHECKING,
+ Any,
+ Type,
+ Union,
+ Generic,
+ TypeVar,
+ Callable,
+ Iterable,
+ Optional,
+ AsyncIterable,
+ cast,
+)
from datetime import date, datetime
from typing_extensions import (
List,
@@ -787,6 +800,7 @@ class FinalRequestOptionsInput(TypedDict, total=False):
timeout: float | Timeout | None
files: HttpxRequestFiles | None
idempotency_key: str
+ content: Union[bytes, bytearray, IO[bytes], Iterable[bytes], AsyncIterable[bytes], None]
json_data: Body
extra_json: AnyMapping
follow_redirects: bool
@@ -805,6 +819,7 @@ class FinalRequestOptions(pydantic.BaseModel):
post_parser: Union[Callable[[Any], Any], NotGiven] = NotGiven()
follow_redirects: Union[bool, None] = None
+ content: Union[bytes, bytearray, IO[bytes], Iterable[bytes], AsyncIterable[bytes], None] = None
# It should be noted that we cannot use `json` here as that would override
# a BaseModel method in an incompatible fashion.
json_data: Union[Body, None] = None
diff --git a/src/mobilerun/_types.py b/src/mobilerun/_types.py
index f9f729e..6fa6541 100644
--- a/src/mobilerun/_types.py
+++ b/src/mobilerun/_types.py
@@ -13,9 +13,11 @@
Mapping,
TypeVar,
Callable,
+ Iterable,
Iterator,
Optional,
Sequence,
+ AsyncIterable,
)
from typing_extensions import (
Set,
@@ -56,6 +58,13 @@
else:
Base64FileInput = Union[IO[bytes], PathLike]
FileContent = Union[IO[bytes], bytes, PathLike] # PathLike is not subscriptable in Python 3.8.
+
+
+# Used for sending raw binary data / streaming data in request bodies
+# e.g. for file uploads without multipart encoding
+BinaryTypes = Union[bytes, bytearray, IO[bytes], Iterable[bytes]]
+AsyncBinaryTypes = Union[bytes, bytearray, IO[bytes], AsyncIterable[bytes]]
+
FileTypes = Union[
# file (or bytes)
FileContent,
diff --git a/src/mobilerun/_version.py b/src/mobilerun/_version.py
index ed7458a..f24f62b 100644
--- a/src/mobilerun/_version.py
+++ b/src/mobilerun/_version.py
@@ -1,4 +1,4 @@
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
__title__ = "mobilerun"
-__version__ = "2.0.0" # x-release-please-version
+__version__ = "2.1.0" # x-release-please-version
diff --git a/src/mobilerun/resources/devices/devices.py b/src/mobilerun/resources/devices/devices.py
index bc1dbe8..63be619 100644
--- a/src/mobilerun/resources/devices/devices.py
+++ b/src/mobilerun/resources/devices/devices.py
@@ -69,6 +69,7 @@
from ..._base_client import make_request_options
from ...types.device import Device
from ...types.device_list_response import DeviceListResponse
+from ...types.device_count_response import DeviceCountResponse
__all__ = ["DevicesResource", "AsyncDevicesResource"]
@@ -263,6 +264,25 @@ def list(
cast_to=DeviceListResponse,
)
+ def count(
+ 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,
+ ) -> DeviceCountResponse:
+ """Count claimed devices"""
+ return self._get(
+ "/devices/count",
+ options=make_request_options(
+ extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
+ ),
+ cast_to=DeviceCountResponse,
+ )
+
def terminate(
self,
device_id: str,
@@ -521,6 +541,25 @@ async def list(
cast_to=DeviceListResponse,
)
+ async def count(
+ 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,
+ ) -> DeviceCountResponse:
+ """Count claimed devices"""
+ return await self._get(
+ "/devices/count",
+ options=make_request_options(
+ extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
+ ),
+ cast_to=DeviceCountResponse,
+ )
+
async def terminate(
self,
device_id: str,
@@ -602,6 +641,9 @@ def __init__(self, devices: DevicesResource) -> None:
self.list = to_raw_response_wrapper(
devices.list,
)
+ self.count = to_raw_response_wrapper(
+ devices.count,
+ )
self.terminate = to_raw_response_wrapper(
devices.terminate,
)
@@ -647,6 +689,9 @@ def __init__(self, devices: AsyncDevicesResource) -> None:
self.list = async_to_raw_response_wrapper(
devices.list,
)
+ self.count = async_to_raw_response_wrapper(
+ devices.count,
+ )
self.terminate = async_to_raw_response_wrapper(
devices.terminate,
)
@@ -692,6 +737,9 @@ def __init__(self, devices: DevicesResource) -> None:
self.list = to_streamed_response_wrapper(
devices.list,
)
+ self.count = to_streamed_response_wrapper(
+ devices.count,
+ )
self.terminate = to_streamed_response_wrapper(
devices.terminate,
)
@@ -737,6 +785,9 @@ def __init__(self, devices: AsyncDevicesResource) -> None:
self.list = async_to_streamed_response_wrapper(
devices.list,
)
+ self.count = async_to_streamed_response_wrapper(
+ devices.count,
+ )
self.terminate = async_to_streamed_response_wrapper(
devices.terminate,
)
diff --git a/src/mobilerun/types/__init__.py b/src/mobilerun/types/__init__.py
index e5925bb..72eddf4 100644
--- a/src/mobilerun/types/__init__.py
+++ b/src/mobilerun/types/__init__.py
@@ -20,6 +20,7 @@
from .device_create_params import DeviceCreateParams as DeviceCreateParams
from .device_list_response import DeviceListResponse as DeviceListResponse
from .hook_update_response import HookUpdateResponse as HookUpdateResponse
+from .device_count_response import DeviceCountResponse as DeviceCountResponse
from .hook_perform_response import HookPerformResponse as HookPerformResponse
from .hook_subscribe_params import HookSubscribeParams as HookSubscribeParams
from .hook_retrieve_response import HookRetrieveResponse as HookRetrieveResponse
diff --git a/src/mobilerun/types/device_count_response.py b/src/mobilerun/types/device_count_response.py
new file mode 100644
index 0000000..517cdeb
--- /dev/null
+++ b/src/mobilerun/types/device_count_response.py
@@ -0,0 +1,22 @@
+# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+from typing import Optional
+
+from pydantic import Field as FieldInfo
+
+from .._models import BaseModel
+
+__all__ = ["DeviceCountResponse"]
+
+
+class DeviceCountResponse(BaseModel):
+ limrun: int
+
+ personal: int
+
+ remote: int
+
+ roidrun: int
+
+ schema_: Optional[str] = FieldInfo(alias="$schema", default=None)
+ """A URL to the JSON Schema for this object."""
diff --git a/src/mobilerun/types/llm_model.py b/src/mobilerun/types/llm_model.py
index b42bc72..f0a0dca 100644
--- a/src/mobilerun/types/llm_model.py
+++ b/src/mobilerun/types/llm_model.py
@@ -5,9 +5,11 @@
__all__ = ["LlmModel"]
LlmModel: TypeAlias = Literal[
- "openai/gpt-5",
+ "openai/gpt-5.1",
+ "openai/gpt-5.2",
"google/gemini-2.5-flash",
"google/gemini-2.5-pro",
+ "google/gemini-3-flash",
"google/gemini-3-pro-preview",
"anthropic/claude-sonnet-4.5",
"minimax/minimax-m2",
diff --git a/tests/api_resources/test_devices.py b/tests/api_resources/test_devices.py
index 0f3babb..f762064 100644
--- a/tests/api_resources/test_devices.py
+++ b/tests/api_resources/test_devices.py
@@ -9,7 +9,7 @@
from mobilerun import Mobilerun, AsyncMobilerun
from tests.utils import assert_matches_type
-from mobilerun.types import Device, DeviceListResponse
+from mobilerun.types import Device, DeviceListResponse, DeviceCountResponse
base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010")
@@ -150,6 +150,34 @@ def test_streaming_response_list(self, client: Mobilerun) -> None:
assert cast(Any, response.is_closed) is True
+ @pytest.mark.skip(reason="Prism tests are disabled")
+ @parametrize
+ def test_method_count(self, client: Mobilerun) -> None:
+ device = client.devices.count()
+ assert_matches_type(DeviceCountResponse, device, path=["response"])
+
+ @pytest.mark.skip(reason="Prism tests are disabled")
+ @parametrize
+ def test_raw_response_count(self, client: Mobilerun) -> None:
+ response = client.devices.with_raw_response.count()
+
+ assert response.is_closed is True
+ assert response.http_request.headers.get("X-Stainless-Lang") == "python"
+ device = response.parse()
+ assert_matches_type(DeviceCountResponse, device, path=["response"])
+
+ @pytest.mark.skip(reason="Prism tests are disabled")
+ @parametrize
+ def test_streaming_response_count(self, client: Mobilerun) -> None:
+ with client.devices.with_streaming_response.count() as response:
+ assert not response.is_closed
+ assert response.http_request.headers.get("X-Stainless-Lang") == "python"
+
+ device = response.parse()
+ assert_matches_type(DeviceCountResponse, device, path=["response"])
+
+ assert cast(Any, response.is_closed) is True
+
@pytest.mark.skip(reason="Prism tests are disabled")
@parametrize
def test_method_terminate(self, client: Mobilerun) -> None:
@@ -373,6 +401,34 @@ async def test_streaming_response_list(self, async_client: AsyncMobilerun) -> No
assert cast(Any, response.is_closed) is True
+ @pytest.mark.skip(reason="Prism tests are disabled")
+ @parametrize
+ async def test_method_count(self, async_client: AsyncMobilerun) -> None:
+ device = await async_client.devices.count()
+ assert_matches_type(DeviceCountResponse, device, path=["response"])
+
+ @pytest.mark.skip(reason="Prism tests are disabled")
+ @parametrize
+ async def test_raw_response_count(self, async_client: AsyncMobilerun) -> None:
+ response = await async_client.devices.with_raw_response.count()
+
+ assert response.is_closed is True
+ assert response.http_request.headers.get("X-Stainless-Lang") == "python"
+ device = await response.parse()
+ assert_matches_type(DeviceCountResponse, device, path=["response"])
+
+ @pytest.mark.skip(reason="Prism tests are disabled")
+ @parametrize
+ async def test_streaming_response_count(self, async_client: AsyncMobilerun) -> None:
+ async with async_client.devices.with_streaming_response.count() as response:
+ assert not response.is_closed
+ assert response.http_request.headers.get("X-Stainless-Lang") == "python"
+
+ device = await response.parse()
+ assert_matches_type(DeviceCountResponse, device, path=["response"])
+
+ assert cast(Any, response.is_closed) is True
+
@pytest.mark.skip(reason="Prism tests are disabled")
@parametrize
async def test_method_terminate(self, async_client: AsyncMobilerun) -> None:
diff --git a/tests/api_resources/test_tasks.py b/tests/api_resources/test_tasks.py
index d14af58..2a19e53 100644
--- a/tests/api_resources/test_tasks.py
+++ b/tests/api_resources/test_tasks.py
@@ -237,7 +237,7 @@ def test_path_params_get_trajectory(self, client: Mobilerun) -> None:
@parametrize
def test_method_run(self, client: Mobilerun) -> None:
task = client.tasks.run(
- llm_model="openai/gpt-5",
+ llm_model="openai/gpt-5.1",
task="x",
)
assert_matches_type(TaskRunResponse, task, path=["response"])
@@ -246,7 +246,7 @@ def test_method_run(self, client: Mobilerun) -> None:
@parametrize
def test_method_run_with_all_params(self, client: Mobilerun) -> None:
task = client.tasks.run(
- llm_model="openai/gpt-5",
+ llm_model="openai/gpt-5.1",
task="x",
apps=["string"],
credentials=[
@@ -272,7 +272,7 @@ def test_method_run_with_all_params(self, client: Mobilerun) -> None:
@parametrize
def test_raw_response_run(self, client: Mobilerun) -> None:
response = client.tasks.with_raw_response.run(
- llm_model="openai/gpt-5",
+ llm_model="openai/gpt-5.1",
task="x",
)
@@ -285,7 +285,7 @@ def test_raw_response_run(self, client: Mobilerun) -> None:
@parametrize
def test_streaming_response_run(self, client: Mobilerun) -> None:
with client.tasks.with_streaming_response.run(
- llm_model="openai/gpt-5",
+ llm_model="openai/gpt-5.1",
task="x",
) as response:
assert not response.is_closed
@@ -300,7 +300,7 @@ def test_streaming_response_run(self, client: Mobilerun) -> None:
@parametrize
def test_method_run_streamed(self, client: Mobilerun) -> None:
task = client.tasks.run_streamed(
- llm_model="openai/gpt-5",
+ llm_model="openai/gpt-5.1",
task="x",
)
assert task is None
@@ -309,7 +309,7 @@ def test_method_run_streamed(self, client: Mobilerun) -> None:
@parametrize
def test_method_run_streamed_with_all_params(self, client: Mobilerun) -> None:
task = client.tasks.run_streamed(
- llm_model="openai/gpt-5",
+ llm_model="openai/gpt-5.1",
task="x",
apps=["string"],
credentials=[
@@ -335,7 +335,7 @@ def test_method_run_streamed_with_all_params(self, client: Mobilerun) -> None:
@parametrize
def test_raw_response_run_streamed(self, client: Mobilerun) -> None:
response = client.tasks.with_raw_response.run_streamed(
- llm_model="openai/gpt-5",
+ llm_model="openai/gpt-5.1",
task="x",
)
@@ -348,7 +348,7 @@ def test_raw_response_run_streamed(self, client: Mobilerun) -> None:
@parametrize
def test_streaming_response_run_streamed(self, client: Mobilerun) -> None:
with client.tasks.with_streaming_response.run_streamed(
- llm_model="openai/gpt-5",
+ llm_model="openai/gpt-5.1",
task="x",
) as response:
assert not response.is_closed
@@ -620,7 +620,7 @@ async def test_path_params_get_trajectory(self, async_client: AsyncMobilerun) ->
@parametrize
async def test_method_run(self, async_client: AsyncMobilerun) -> None:
task = await async_client.tasks.run(
- llm_model="openai/gpt-5",
+ llm_model="openai/gpt-5.1",
task="x",
)
assert_matches_type(TaskRunResponse, task, path=["response"])
@@ -629,7 +629,7 @@ async def test_method_run(self, async_client: AsyncMobilerun) -> None:
@parametrize
async def test_method_run_with_all_params(self, async_client: AsyncMobilerun) -> None:
task = await async_client.tasks.run(
- llm_model="openai/gpt-5",
+ llm_model="openai/gpt-5.1",
task="x",
apps=["string"],
credentials=[
@@ -655,7 +655,7 @@ async def test_method_run_with_all_params(self, async_client: AsyncMobilerun) ->
@parametrize
async def test_raw_response_run(self, async_client: AsyncMobilerun) -> None:
response = await async_client.tasks.with_raw_response.run(
- llm_model="openai/gpt-5",
+ llm_model="openai/gpt-5.1",
task="x",
)
@@ -668,7 +668,7 @@ async def test_raw_response_run(self, async_client: AsyncMobilerun) -> None:
@parametrize
async def test_streaming_response_run(self, async_client: AsyncMobilerun) -> None:
async with async_client.tasks.with_streaming_response.run(
- llm_model="openai/gpt-5",
+ llm_model="openai/gpt-5.1",
task="x",
) as response:
assert not response.is_closed
@@ -683,7 +683,7 @@ async def test_streaming_response_run(self, async_client: AsyncMobilerun) -> Non
@parametrize
async def test_method_run_streamed(self, async_client: AsyncMobilerun) -> None:
task = await async_client.tasks.run_streamed(
- llm_model="openai/gpt-5",
+ llm_model="openai/gpt-5.1",
task="x",
)
assert task is None
@@ -692,7 +692,7 @@ async def test_method_run_streamed(self, async_client: AsyncMobilerun) -> None:
@parametrize
async def test_method_run_streamed_with_all_params(self, async_client: AsyncMobilerun) -> None:
task = await async_client.tasks.run_streamed(
- llm_model="openai/gpt-5",
+ llm_model="openai/gpt-5.1",
task="x",
apps=["string"],
credentials=[
@@ -718,7 +718,7 @@ async def test_method_run_streamed_with_all_params(self, async_client: AsyncMobi
@parametrize
async def test_raw_response_run_streamed(self, async_client: AsyncMobilerun) -> None:
response = await async_client.tasks.with_raw_response.run_streamed(
- llm_model="openai/gpt-5",
+ llm_model="openai/gpt-5.1",
task="x",
)
@@ -731,7 +731,7 @@ async def test_raw_response_run_streamed(self, async_client: AsyncMobilerun) ->
@parametrize
async def test_streaming_response_run_streamed(self, async_client: AsyncMobilerun) -> None:
async with async_client.tasks.with_streaming_response.run_streamed(
- llm_model="openai/gpt-5",
+ llm_model="openai/gpt-5.1",
task="x",
) as response:
assert not response.is_closed
diff --git a/tests/test_client.py b/tests/test_client.py
index add5de8..8c4f9eb 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -8,10 +8,11 @@
import json
import asyncio
import inspect
+import dataclasses
import tracemalloc
-from typing import Any, Union, cast
+from typing import Any, Union, TypeVar, Callable, Iterable, Iterator, Optional, Coroutine, cast
from unittest import mock
-from typing_extensions import Literal
+from typing_extensions import Literal, AsyncIterator, override
import httpx
import pytest
@@ -36,6 +37,7 @@
from .utils import update_env
+T = TypeVar("T")
base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010")
api_key = "My API Key"
@@ -50,6 +52,57 @@ def _low_retry_timeout(*_args: Any, **_kwargs: Any) -> float:
return 0.1
+def mirror_request_content(request: httpx.Request) -> httpx.Response:
+ return httpx.Response(200, content=request.content)
+
+
+# note: we can't use the httpx.MockTransport class as it consumes the request
+# body itself, which means we can't test that the body is read lazily
+class MockTransport(httpx.BaseTransport, httpx.AsyncBaseTransport):
+ def __init__(
+ self,
+ handler: Callable[[httpx.Request], httpx.Response]
+ | Callable[[httpx.Request], Coroutine[Any, Any, httpx.Response]],
+ ) -> None:
+ self.handler = handler
+
+ @override
+ def handle_request(
+ self,
+ request: httpx.Request,
+ ) -> httpx.Response:
+ assert not inspect.iscoroutinefunction(self.handler), "handler must not be a coroutine function"
+ assert inspect.isfunction(self.handler), "handler must be a function"
+ return self.handler(request)
+
+ @override
+ async def handle_async_request(
+ self,
+ request: httpx.Request,
+ ) -> httpx.Response:
+ assert inspect.iscoroutinefunction(self.handler), "handler must be a coroutine function"
+ return await self.handler(request)
+
+
+@dataclasses.dataclass
+class Counter:
+ value: int = 0
+
+
+def _make_sync_iterator(iterable: Iterable[T], counter: Optional[Counter] = None) -> Iterator[T]:
+ for item in iterable:
+ if counter:
+ counter.value += 1
+ yield item
+
+
+async def _make_async_iterator(iterable: Iterable[T], counter: Optional[Counter] = None) -> AsyncIterator[T]:
+ for item in iterable:
+ if counter:
+ counter.value += 1
+ yield item
+
+
def _get_open_connections(client: Mobilerun | AsyncMobilerun) -> int:
transport = client._client._transport
assert isinstance(transport, httpx.HTTPTransport) or isinstance(transport, httpx.AsyncHTTPTransport)
@@ -511,6 +564,70 @@ def test_multipart_repeating_array(self, client: Mobilerun) -> None:
b"",
]
+ @pytest.mark.respx(base_url=base_url)
+ def test_binary_content_upload(self, respx_mock: MockRouter, client: Mobilerun) -> None:
+ respx_mock.post("/upload").mock(side_effect=mirror_request_content)
+
+ file_content = b"Hello, this is a test file."
+
+ response = client.post(
+ "/upload",
+ content=file_content,
+ cast_to=httpx.Response,
+ options={"headers": {"Content-Type": "application/octet-stream"}},
+ )
+
+ assert response.status_code == 200
+ assert response.request.headers["Content-Type"] == "application/octet-stream"
+ assert response.content == file_content
+
+ def test_binary_content_upload_with_iterator(self) -> None:
+ file_content = b"Hello, this is a test file."
+ counter = Counter()
+ iterator = _make_sync_iterator([file_content], counter=counter)
+
+ def mock_handler(request: httpx.Request) -> httpx.Response:
+ assert counter.value == 0, "the request body should not have been read"
+ return httpx.Response(200, content=request.read())
+
+ with Mobilerun(
+ base_url=base_url,
+ api_key=api_key,
+ _strict_response_validation=True,
+ http_client=httpx.Client(transport=MockTransport(handler=mock_handler)),
+ ) as client:
+ response = client.post(
+ "/upload",
+ content=iterator,
+ cast_to=httpx.Response,
+ options={"headers": {"Content-Type": "application/octet-stream"}},
+ )
+
+ assert response.status_code == 200
+ assert response.request.headers["Content-Type"] == "application/octet-stream"
+ assert response.content == file_content
+ assert counter.value == 1
+
+ @pytest.mark.respx(base_url=base_url)
+ def test_binary_content_upload_with_body_is_deprecated(self, respx_mock: MockRouter, client: Mobilerun) -> None:
+ respx_mock.post("/upload").mock(side_effect=mirror_request_content)
+
+ file_content = b"Hello, this is a test file."
+
+ with pytest.deprecated_call(
+ match="Passing raw bytes as `body` is deprecated and will be removed in a future version. Please pass raw bytes via the `content` parameter instead."
+ ):
+ response = client.post(
+ "/upload",
+ body=file_content,
+ cast_to=httpx.Response,
+ options={"headers": {"Content-Type": "application/octet-stream"}},
+ )
+
+ assert response.status_code == 200
+ assert response.request.headers["Content-Type"] == "application/octet-stream"
+ assert response.content == file_content
+
@pytest.mark.respx(base_url=base_url)
def test_basic_union_response(self, respx_mock: MockRouter, client: Mobilerun) -> None:
class Model1(BaseModel):
@@ -1339,6 +1456,72 @@ def test_multipart_repeating_array(self, async_client: AsyncMobilerun) -> None:
b"",
]
+ @pytest.mark.respx(base_url=base_url)
+ async def test_binary_content_upload(self, respx_mock: MockRouter, async_client: AsyncMobilerun) -> None:
+ respx_mock.post("/upload").mock(side_effect=mirror_request_content)
+
+ file_content = b"Hello, this is a test file."
+
+ response = await async_client.post(
+ "/upload",
+ content=file_content,
+ cast_to=httpx.Response,
+ options={"headers": {"Content-Type": "application/octet-stream"}},
+ )
+
+ assert response.status_code == 200
+ assert response.request.headers["Content-Type"] == "application/octet-stream"
+ assert response.content == file_content
+
+ async def test_binary_content_upload_with_asynciterator(self) -> None:
+ file_content = b"Hello, this is a test file."
+ counter = Counter()
+ iterator = _make_async_iterator([file_content], counter=counter)
+
+ async def mock_handler(request: httpx.Request) -> httpx.Response:
+ assert counter.value == 0, "the request body should not have been read"
+ return httpx.Response(200, content=await request.aread())
+
+ async with AsyncMobilerun(
+ base_url=base_url,
+ api_key=api_key,
+ _strict_response_validation=True,
+ http_client=httpx.AsyncClient(transport=MockTransport(handler=mock_handler)),
+ ) as client:
+ response = await client.post(
+ "/upload",
+ content=iterator,
+ cast_to=httpx.Response,
+ options={"headers": {"Content-Type": "application/octet-stream"}},
+ )
+
+ assert response.status_code == 200
+ assert response.request.headers["Content-Type"] == "application/octet-stream"
+ assert response.content == file_content
+ assert counter.value == 1
+
+ @pytest.mark.respx(base_url=base_url)
+ async def test_binary_content_upload_with_body_is_deprecated(
+ self, respx_mock: MockRouter, async_client: AsyncMobilerun
+ ) -> None:
+ respx_mock.post("/upload").mock(side_effect=mirror_request_content)
+
+ file_content = b"Hello, this is a test file."
+
+ with pytest.deprecated_call(
+ match="Passing raw bytes as `body` is deprecated and will be removed in a future version. Please pass raw bytes via the `content` parameter instead."
+ ):
+ response = await async_client.post(
+ "/upload",
+ body=file_content,
+ cast_to=httpx.Response,
+ options={"headers": {"Content-Type": "application/octet-stream"}},
+ )
+
+ assert response.status_code == 200
+ assert response.request.headers["Content-Type"] == "application/octet-stream"
+ assert response.content == file_content
+
@pytest.mark.respx(base_url=base_url)
async def test_basic_union_response(self, respx_mock: MockRouter, async_client: AsyncMobilerun) -> None:
class Model1(BaseModel):