diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e5dc504..d721fb5 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: |
@@ -63,7 +63,7 @@ jobs:
- name: Get GitHub OIDC Token
if: github.repository == 'stainless-sdks/droidrun-cloud-python'
id: github-oidc
- uses: actions/github-script@v6
+ uses: actions/github-script@v8
with:
script: core.setOutput('github_token', await core.getIDToken());
@@ -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..21aafc2 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: 51
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/droidrun%2Fdroidrun-cloud-36ef2a444a065b24fda25e8cb3e6612e88b7056d3f34f5b1e8715b63bf52163a.yml
+openapi_spec_hash: c5ccdf970829be56cad60a1f1bd797d6
+config_hash: 301a5a6069197d416d0877c4d3fc0fb0
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ffa3740..9f727a1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,40 @@
# Changelog
+## 2.1.0 (2026-02-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 ([fdc6d3c](https://github.com/droidrun/mobilerun-sdk-python/commit/fdc6d3cd2d2ecfce66ae84ef34f8111f47fbf609))
+* **api:** api update ([543bbd8](https://github.com/droidrun/mobilerun-sdk-python/commit/543bbd877819b2dc23ecfe5b3d4722996b54e92b))
+* **api:** api update ([9049554](https://github.com/droidrun/mobilerun-sdk-python/commit/9049554b1ab39ca164a3351861ec41c9ea78d489))
+* **api:** api update ([4c2d97d](https://github.com/droidrun/mobilerun-sdk-python/commit/4c2d97de0a1ae482871912fe677ed64e0919825e))
+* **api:** api update ([8d53e63](https://github.com/droidrun/mobilerun-sdk-python/commit/8d53e63fd8ccebbe5a77af740cd5e6bdf85c2a85))
+* **api:** api update ([678264c](https://github.com/droidrun/mobilerun-sdk-python/commit/678264c95364ad4fff7c70b61bd4794819f28292))
+* **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))
+* **api:** manual updates ([e529cf6](https://github.com/droidrun/mobilerun-sdk-python/commit/e529cf666a09be899a1af449735a2759b307762b))
+* **api:** manual updates ([a27d844](https://github.com/droidrun/mobilerun-sdk-python/commit/a27d844d5ca5cecc754a2a3e0eb88712a8540d43))
+* **client:** add custom JSON encoder for extended type support ([9471952](https://github.com/droidrun/mobilerun-sdk-python/commit/947195285cae03d555b21716015384cc2e1f3fa0))
+* **client:** add support for binary request streaming ([c6668af](https://github.com/droidrun/mobilerun-sdk-python/commit/c6668af5dbd83d7ab1dd1fe4f68253422e055e73))
+
+
+### Bug Fixes
+
+* **docs:** fix mcp installation instructions for remote servers ([a06f4b2](https://github.com/droidrun/mobilerun-sdk-python/commit/a06f4b2146dafd2c9435fecb603a6217b085fcc5))
+
+
+### Chores
+
+* **ci:** upgrade `actions/github-script` ([00033ad](https://github.com/droidrun/mobilerun-sdk-python/commit/00033ad7ffac1d3d650a05e841bbdecca964192e))
+* format all `api.md` files ([2910e43](https://github.com/droidrun/mobilerun-sdk-python/commit/2910e4312fa6360f754de4d6937d677cd04acc68))
+* **internal:** bump dependencies ([91ab063](https://github.com/droidrun/mobilerun-sdk-python/commit/91ab0631121c4ca096133953344ea83e96cc32ae))
+* **internal:** fix lint error on Python 3.14 ([f5920bc](https://github.com/droidrun/mobilerun-sdk-python/commit/f5920bc46fb52360860c02ca941926e939f3e316))
+* **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/README.md b/README.md
index 0eece63..fd577f2 100644
--- a/README.md
+++ b/README.md
@@ -13,8 +13,8 @@ It is generated with [Stainless](https://www.stainless.com/).
Use the Mobilerun MCP Server to enable AI assistants to interact with this API, allowing them to explore endpoints, make test requests, and use documentation to help integrate this SDK into your application.
-[](https://cursor.com/en-US/install-mcp?name=mobilerun-mcp&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIm1vYmlsZXJ1bi1tY3AiXX0)
-[](https://vscode.stainless.com/mcp/%7B%22name%22%3A%22mobilerun-mcp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22mobilerun-mcp%22%5D%7D)
+[](https://cursor.com/en-US/install-mcp?name=mobilerun-mcp&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIm1vYmlsZXJ1bi1tY3AiXSwiZW52Ijp7Ik1PQklMRVJVTl9DTE9VRF9BUElfS0VZIjoiTXkgQVBJIEtleSJ9fQ)
+[](https://vscode.stainless.com/mcp/%7B%22name%22%3A%22mobilerun-mcp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22mobilerun-mcp%22%5D%2C%22env%22%3A%7B%22MOBILERUN_CLOUD_API_KEY%22%3A%22My%20API%20Key%22%7D%7D)
> Note: You may need to set environment variables in your MCP client.
diff --git a/api.md b/api.md
index 20d08ea..1d226a3 100644
--- a/api.md
+++ b/api.md
@@ -6,7 +6,6 @@ Types:
from mobilerun.types import (
LlmModel,
Task,
- TaskCreate,
TaskStatus,
TaskRetrieveResponse,
TaskListResponse,
@@ -20,12 +19,12 @@ from mobilerun.types import (
Methods:
- client.tasks.retrieve(task_id) -> TaskRetrieveResponse
-- client.tasks.list(\*\*params) -> TaskListResponse
+- client.tasks.list(\*\*params) -> TaskListResponse
- client.tasks.attach(task_id) -> None
- client.tasks.get_status(task_id) -> TaskGetStatusResponse
- client.tasks.get_trajectory(task_id) -> TaskGetTrajectoryResponse
-- client.tasks.run(\*\*params) -> TaskRunResponse
-- client.tasks.run_streamed(\*\*params) -> None
+- client.tasks.run() -> TaskRunResponse
+- client.tasks.run_streamed() -> None
- client.tasks.stop(task_id) -> TaskStopResponse
## Screenshots
@@ -59,7 +58,7 @@ Methods:
Types:
```python
-from mobilerun.types import Device, DeviceListResponse
+from mobilerun.types import Device, DeviceListResponse, DeviceCountResponse
```
Methods:
@@ -67,7 +66,8 @@ Methods:
- client.devices.create(\*\*params) -> Device
- client.devices.retrieve(device_id) -> Device
- client.devices.list(\*\*params) -> DeviceListResponse
-- client.devices.terminate(device_id) -> None
+- client.devices.count() -> DeviceCountResponse
+- client.devices.terminate(device_id, \*\*params) -> None
- client.devices.wait_ready(device_id) -> Device
## Actions
@@ -102,6 +102,7 @@ from mobilerun.types.devices import AppListResponse
Methods:
+- client.devices.apps.update(package_name, \*, device_id) -> None
- client.devices.apps.list(device_id, \*\*params) -> Optional[AppListResponse]
- client.devices.apps.delete(package_name, \*, device_id) -> None
- client.devices.apps.install(device_id, \*\*params) -> None
@@ -161,7 +162,7 @@ from mobilerun.types import CredentialListResponse
Methods:
-- client.credentials.list() -> CredentialListResponse
+- client.credentials.list(\*\*params) -> CredentialListResponse
## Packages
@@ -233,7 +234,7 @@ Methods:
- client.hooks.retrieve(hook_id) -> HookRetrieveResponse
- client.hooks.update(hook_id, \*\*params) -> HookUpdateResponse
-- client.hooks.list(\*\*params) -> HookListResponse
+- client.hooks.list(\*\*params) -> HookListResponse
- client.hooks.get_sample_data() -> HookGetSampleDataResponse
- client.hooks.perform() -> HookPerformResponse
- client.hooks.subscribe(\*\*params) -> HookSubscribeResponse
diff --git a/pyproject.toml b/pyproject.toml
index 4874197..7719b58 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"
@@ -69,7 +69,7 @@ format = { chain = [
# run formatting again to fix any inconsistencies when imports are stripped
"format:ruff",
]}
-"format:docs" = "python scripts/utils/ruffen-docs.py README.md api.md"
+"format:docs" = "bash -c 'python scripts/utils/ruffen-docs.py README.md $(find . -type f -name api.md)'"
"format:ruff" = "ruff format"
"lint" = { chain = [
diff --git a/requirements-dev.lock b/requirements-dev.lock
index 49213b4..bd2a014 100644
--- a/requirements-dev.lock
+++ b/requirements-dev.lock
@@ -12,14 +12,14 @@
-e file:.
aiohappyeyeballs==2.6.1
# via aiohttp
-aiohttp==3.13.2
+aiohttp==3.13.3
# via httpx-aiohttp
# via mobilerun-sdk
aiosignal==1.4.0
# via aiohttp
annotated-types==0.7.0
# via pydantic
-anyio==4.12.0
+anyio==4.12.1
# via httpx
# via mobilerun-sdk
argcomplete==3.6.3
@@ -31,7 +31,7 @@ attrs==25.4.0
# via nox
backports-asyncio-runner==1.2.0
# via pytest-asyncio
-certifi==2025.11.12
+certifi==2026.1.4
# via httpcore
# via httpx
colorlog==6.10.1
@@ -61,7 +61,7 @@ httpx==0.28.1
# via httpx-aiohttp
# via mobilerun-sdk
# via respx
-httpx-aiohttp==0.1.9
+httpx-aiohttp==0.1.12
# via mobilerun-sdk
humanize==4.13.0
# via nox
@@ -69,7 +69,7 @@ idna==3.11
# via anyio
# via httpx
# via yarl
-importlib-metadata==8.7.0
+importlib-metadata==8.7.1
iniconfig==2.1.0
# via pytest
markdown-it-py==3.0.0
@@ -82,14 +82,14 @@ multidict==6.7.0
mypy==1.17.0
mypy-extensions==1.1.0
# via mypy
-nodeenv==1.9.1
+nodeenv==1.10.0
# via pyright
nox==2025.11.12
packaging==25.0
# via dependency-groups
# via nox
# via pytest
-pathspec==0.12.1
+pathspec==1.0.3
# via mypy
platformdirs==4.4.0
# via virtualenv
@@ -115,13 +115,13 @@ python-dateutil==2.9.0.post0
# via time-machine
respx==0.22.0
rich==14.2.0
-ruff==0.14.7
+ruff==0.14.13
six==1.17.0
# via python-dateutil
sniffio==1.3.1
# via mobilerun-sdk
time-machine==2.19.0
-tomli==2.3.0
+tomli==2.4.0
# via dependency-groups
# via mypy
# via nox
@@ -141,7 +141,7 @@ typing-extensions==4.15.0
# via virtualenv
typing-inspection==0.4.2
# via pydantic
-virtualenv==20.35.4
+virtualenv==20.36.1
# via nox
yarl==1.22.0
# via aiohttp
diff --git a/requirements.lock b/requirements.lock
index 17e9b83..5c59b39 100644
--- a/requirements.lock
+++ b/requirements.lock
@@ -12,21 +12,21 @@
-e file:.
aiohappyeyeballs==2.6.1
# via aiohttp
-aiohttp==3.13.2
+aiohttp==3.13.3
# via httpx-aiohttp
# via mobilerun-sdk
aiosignal==1.4.0
# via aiohttp
annotated-types==0.7.0
# via pydantic
-anyio==4.12.0
+anyio==4.12.1
# via httpx
# via mobilerun-sdk
async-timeout==5.0.1
# via aiohttp
attrs==25.4.0
# via aiohttp
-certifi==2025.11.12
+certifi==2026.1.4
# via httpcore
# via httpx
distro==1.9.0
@@ -43,7 +43,7 @@ httpcore==1.0.9
httpx==0.28.1
# via httpx-aiohttp
# via mobilerun-sdk
-httpx-aiohttp==0.1.9
+httpx-aiohttp==0.1.12
# via mobilerun-sdk
idna==3.11
# via anyio
diff --git a/src/mobilerun/_base_client.py b/src/mobilerun/_base_client.py
index f24e0dc..19e64c7 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,
@@ -83,6 +86,7 @@
APIConnectionError,
APIResponseValidationError,
)
+from ._utils._json import openapi_dumps
log: logging.Logger = logging.getLogger(__name__)
@@ -477,8 +481,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,10 +547,18 @@ 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
+ elif not files:
+ # Don't set content when JSON is sent as multipart/form-data,
+ # since httpx's content param overrides other body arguments
+ kwargs["content"] = openapi_dumps(json_data) if is_given(json_data) and json_data is not None else None
kwargs["files"] = files
else:
headers.pop("Content-Type", None)
@@ -1194,6 +1217,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 +1230,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 +1244,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 +1257,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 +1285,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 +1311,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 +1337,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 +1789,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 +1802,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 +1816,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 +1829,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 +1857,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 +1888,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 +1914,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/_compat.py b/src/mobilerun/_compat.py
index bdef67f..786ff42 100644
--- a/src/mobilerun/_compat.py
+++ b/src/mobilerun/_compat.py
@@ -139,6 +139,7 @@ def model_dump(
exclude_defaults: bool = False,
warnings: bool = True,
mode: Literal["json", "python"] = "python",
+ by_alias: bool | None = None,
) -> dict[str, Any]:
if (not PYDANTIC_V1) or hasattr(model, "model_dump"):
return model.model_dump(
@@ -148,13 +149,12 @@ def model_dump(
exclude_defaults=exclude_defaults,
# warnings are not supported in Pydantic v1
warnings=True if PYDANTIC_V1 else warnings,
+ by_alias=by_alias,
)
return cast(
"dict[str, Any]",
model.dict( # pyright: ignore[reportDeprecated, reportUnnecessaryCast]
- exclude=exclude,
- exclude_unset=exclude_unset,
- exclude_defaults=exclude_defaults,
+ exclude=exclude, exclude_unset=exclude_unset, exclude_defaults=exclude_defaults, by_alias=bool(by_alias)
),
)
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/_utils/_compat.py b/src/mobilerun/_utils/_compat.py
index dd70323..2c70b29 100644
--- a/src/mobilerun/_utils/_compat.py
+++ b/src/mobilerun/_utils/_compat.py
@@ -26,7 +26,7 @@ def is_union(tp: Optional[Type[Any]]) -> bool:
else:
import types
- return tp is Union or tp is types.UnionType
+ return tp is Union or tp is types.UnionType # type: ignore[comparison-overlap]
def is_typeddict(tp: Type[Any]) -> bool:
diff --git a/src/mobilerun/_utils/_json.py b/src/mobilerun/_utils/_json.py
new file mode 100644
index 0000000..6058421
--- /dev/null
+++ b/src/mobilerun/_utils/_json.py
@@ -0,0 +1,35 @@
+import json
+from typing import Any
+from datetime import datetime
+from typing_extensions import override
+
+import pydantic
+
+from .._compat import model_dump
+
+
+def openapi_dumps(obj: Any) -> bytes:
+ """
+ Serialize an object to UTF-8 encoded JSON bytes.
+
+ Extends the standard json.dumps with support for additional types
+ commonly used in the SDK, such as `datetime`, `pydantic.BaseModel`, etc.
+ """
+ return json.dumps(
+ obj,
+ cls=_CustomEncoder,
+ # Uses the same defaults as httpx's JSON serialization
+ ensure_ascii=False,
+ separators=(",", ":"),
+ allow_nan=False,
+ ).encode()
+
+
+class _CustomEncoder(json.JSONEncoder):
+ @override
+ def default(self, o: Any) -> Any:
+ if isinstance(o, datetime):
+ return o.isoformat()
+ if isinstance(o, pydantic.BaseModel):
+ return model_dump(o, exclude_unset=True, mode="json", by_alias=True)
+ return super().default(o)
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/credentials/credentials.py b/src/mobilerun/resources/credentials/credentials.py
index 3bf9957..43d30f4 100644
--- a/src/mobilerun/resources/credentials/credentials.py
+++ b/src/mobilerun/resources/credentials/credentials.py
@@ -4,7 +4,9 @@
import httpx
-from ..._types import Body, Query, Headers, NotGiven, not_given
+from ...types import credential_list_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 (
@@ -54,6 +56,8 @@ def with_streaming_response(self) -> CredentialsResourceWithStreamingResponse:
def list(
self,
*,
+ page: int | Omit = omit,
+ page_size: int | 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,
@@ -61,11 +65,32 @@ def list(
extra_body: Body | None = None,
timeout: float | httpx.Timeout | None | NotGiven = not_given,
) -> CredentialListResponse:
- """List all credentials for the authenticated user"""
+ """
+ List all credentials for the authenticated user
+
+ Args:
+ 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._get(
"/credentials",
options=make_request_options(
- extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ query=maybe_transform(
+ {
+ "page": page,
+ "page_size": page_size,
+ },
+ credential_list_params.CredentialListParams,
+ ),
),
cast_to=CredentialListResponse,
)
@@ -98,6 +123,8 @@ def with_streaming_response(self) -> AsyncCredentialsResourceWithStreamingRespon
async def list(
self,
*,
+ page: int | Omit = omit,
+ page_size: int | 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,
@@ -105,11 +132,32 @@ async def list(
extra_body: Body | None = None,
timeout: float | httpx.Timeout | None | NotGiven = not_given,
) -> CredentialListResponse:
- """List all credentials for the authenticated user"""
+ """
+ List all credentials for the authenticated user
+
+ Args:
+ 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._get(
"/credentials",
options=make_request_options(
- extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ query=await async_maybe_transform(
+ {
+ "page": page,
+ "page_size": page_size,
+ },
+ credential_list_params.CredentialListParams,
+ ),
),
cast_to=CredentialListResponse,
)
diff --git a/src/mobilerun/resources/devices/apps.py b/src/mobilerun/resources/devices/apps.py
index e0ddd9b..f7f1d36 100644
--- a/src/mobilerun/resources/devices/apps.py
+++ b/src/mobilerun/resources/devices/apps.py
@@ -43,6 +43,50 @@ def with_streaming_response(self) -> AppsResourceWithStreamingResponse:
"""
return AppsResourceWithStreamingResponse(self)
+ def update(
+ self,
+ package_name: str,
+ *,
+ device_id: str,
+ x_device_display_id: int | 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,
+ ) -> None:
+ """
+ Stop app
+
+ Args:
+ extra_headers: Send extra headers
+
+ extra_query: Add additional query parameters to the request
+
+ extra_body: Add additional JSON properties to the request
+
+ timeout: Override the client-level default timeout for this request, in seconds
+ """
+ if not device_id:
+ raise ValueError(f"Expected a non-empty value for `device_id` but received {device_id!r}")
+ if not package_name:
+ raise ValueError(f"Expected a non-empty value for `package_name` but received {package_name!r}")
+ extra_headers = {"Accept": "*/*", **(extra_headers or {})}
+ extra_headers = {
+ **strip_not_given(
+ {"X-Device-Display-ID": str(x_device_display_id) if is_given(x_device_display_id) else not_given}
+ ),
+ **(extra_headers or {}),
+ }
+ return self._patch(
+ f"/devices/{device_id}/apps/{package_name}",
+ options=make_request_options(
+ extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
+ ),
+ cast_to=NoneType,
+ )
+
def list(
self,
device_id: str,
@@ -242,6 +286,50 @@ def with_streaming_response(self) -> AsyncAppsResourceWithStreamingResponse:
"""
return AsyncAppsResourceWithStreamingResponse(self)
+ async def update(
+ self,
+ package_name: str,
+ *,
+ device_id: str,
+ x_device_display_id: int | 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,
+ ) -> None:
+ """
+ Stop app
+
+ Args:
+ extra_headers: Send extra headers
+
+ extra_query: Add additional query parameters to the request
+
+ extra_body: Add additional JSON properties to the request
+
+ timeout: Override the client-level default timeout for this request, in seconds
+ """
+ if not device_id:
+ raise ValueError(f"Expected a non-empty value for `device_id` but received {device_id!r}")
+ if not package_name:
+ raise ValueError(f"Expected a non-empty value for `package_name` but received {package_name!r}")
+ extra_headers = {"Accept": "*/*", **(extra_headers or {})}
+ extra_headers = {
+ **strip_not_given(
+ {"X-Device-Display-ID": str(x_device_display_id) if is_given(x_device_display_id) else not_given}
+ ),
+ **(extra_headers or {}),
+ }
+ return await self._patch(
+ f"/devices/{device_id}/apps/{package_name}",
+ options=make_request_options(
+ extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
+ ),
+ cast_to=NoneType,
+ )
+
async def list(
self,
device_id: str,
@@ -427,6 +515,9 @@ class AppsResourceWithRawResponse:
def __init__(self, apps: AppsResource) -> None:
self._apps = apps
+ self.update = to_raw_response_wrapper(
+ apps.update,
+ )
self.list = to_raw_response_wrapper(
apps.list,
)
@@ -445,6 +536,9 @@ class AsyncAppsResourceWithRawResponse:
def __init__(self, apps: AsyncAppsResource) -> None:
self._apps = apps
+ self.update = async_to_raw_response_wrapper(
+ apps.update,
+ )
self.list = async_to_raw_response_wrapper(
apps.list,
)
@@ -463,6 +557,9 @@ class AppsResourceWithStreamingResponse:
def __init__(self, apps: AppsResource) -> None:
self._apps = apps
+ self.update = to_streamed_response_wrapper(
+ apps.update,
+ )
self.list = to_streamed_response_wrapper(
apps.list,
)
@@ -481,6 +578,9 @@ class AsyncAppsResourceWithStreamingResponse:
def __init__(self, apps: AsyncAppsResource) -> None:
self._apps = apps
+ self.update = async_to_streamed_response_wrapper(
+ apps.update,
+ )
self.list = async_to_streamed_response_wrapper(
apps.list,
)
diff --git a/src/mobilerun/resources/devices/devices.py b/src/mobilerun/resources/devices/devices.py
index bc1dbe8..22936d9 100644
--- a/src/mobilerun/resources/devices/devices.py
+++ b/src/mobilerun/resources/devices/devices.py
@@ -2,7 +2,8 @@
from __future__ import annotations
-from typing import Optional
+from typing import List, Union, Optional
+from datetime import datetime
from typing_extensions import Literal
import httpx
@@ -31,7 +32,7 @@
TasksResourceWithStreamingResponse,
AsyncTasksResourceWithStreamingResponse,
)
-from ...types import device_list_params, device_create_params
+from ...types import device_list_params, device_create_params, device_terminate_params
from .actions import (
ActionsResource,
AsyncActionsResource,
@@ -69,6 +70,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"]
@@ -120,8 +122,11 @@ def with_streaming_response(self) -> DevicesResourceWithStreamingResponse:
def create(
self,
*,
- device_type: Literal["device_slot", "dedicated_emulated_device", "dedicated_physical_device"] | Omit = omit,
- provider: Literal["limrun", "remote", "roidrun"] | Omit = omit,
+ device_type: Literal[
+ "device_slot", "dedicated_emulated_device", "dedicated_physical_device", "dedicated_premium_device"
+ ]
+ | Omit = omit,
+ provider: Literal["limrun", "physical", "premium", "roidrun"] | Omit = omit,
apps: Optional[SequenceNotStr[str]] | Omit = omit,
country: str | Omit = omit,
files: Optional[SequenceNotStr[str]] | Omit = omit,
@@ -217,7 +222,8 @@ def list(
page: int | Omit = omit,
page_size: int | Omit = omit,
provider: Literal["limrun", "personal", "remote", "roidrun"] | Omit = omit,
- state: Literal["creating", "assigned", "ready", "terminated", "unknown"] | Omit = omit,
+ state: Optional[List[Literal["creating", "assigned", "ready", "disconnected", "terminated", "unknown"]]]
+ | Omit = omit,
type: Literal["device_slot", "dedicated_emulated_device", "dedicated_physical_device"] | 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.
@@ -263,10 +269,31 @@ 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,
*,
+ previous_device_id: str | Omit = omit,
+ terminate_at: Union[str, datetime] | 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,
@@ -291,6 +318,13 @@ def terminate(
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
return self._delete(
f"/devices/{device_id}",
+ body=maybe_transform(
+ {
+ "previous_device_id": previous_device_id,
+ "terminate_at": terminate_at,
+ },
+ device_terminate_params.DeviceTerminateParams,
+ ),
options=make_request_options(
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
),
@@ -378,8 +412,11 @@ def with_streaming_response(self) -> AsyncDevicesResourceWithStreamingResponse:
async def create(
self,
*,
- device_type: Literal["device_slot", "dedicated_emulated_device", "dedicated_physical_device"] | Omit = omit,
- provider: Literal["limrun", "remote", "roidrun"] | Omit = omit,
+ device_type: Literal[
+ "device_slot", "dedicated_emulated_device", "dedicated_physical_device", "dedicated_premium_device"
+ ]
+ | Omit = omit,
+ provider: Literal["limrun", "physical", "premium", "roidrun"] | Omit = omit,
apps: Optional[SequenceNotStr[str]] | Omit = omit,
country: str | Omit = omit,
files: Optional[SequenceNotStr[str]] | Omit = omit,
@@ -475,7 +512,8 @@ async def list(
page: int | Omit = omit,
page_size: int | Omit = omit,
provider: Literal["limrun", "personal", "remote", "roidrun"] | Omit = omit,
- state: Literal["creating", "assigned", "ready", "terminated", "unknown"] | Omit = omit,
+ state: Optional[List[Literal["creating", "assigned", "ready", "disconnected", "terminated", "unknown"]]]
+ | Omit = omit,
type: Literal["device_slot", "dedicated_emulated_device", "dedicated_physical_device"] | 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.
@@ -521,10 +559,31 @@ 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,
*,
+ previous_device_id: str | Omit = omit,
+ terminate_at: Union[str, datetime] | 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,
@@ -549,6 +608,13 @@ async def terminate(
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
return await self._delete(
f"/devices/{device_id}",
+ body=await async_maybe_transform(
+ {
+ "previous_device_id": previous_device_id,
+ "terminate_at": terminate_at,
+ },
+ device_terminate_params.DeviceTerminateParams,
+ ),
options=make_request_options(
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
),
@@ -602,6 +668,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 +716,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 +764,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 +812,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/resources/hooks.py b/src/mobilerun/resources/hooks.py
index b410998..54e3c01 100644
--- a/src/mobilerun/resources/hooks.py
+++ b/src/mobilerun/resources/hooks.py
@@ -158,7 +158,7 @@ def list(
timeout: Override the client-level default timeout for this request, in seconds
"""
return self._get(
- "/hooks/",
+ "/hooks",
options=make_request_options(
extra_headers=extra_headers,
extra_query=extra_query,
@@ -428,7 +428,7 @@ async def list(
timeout: Override the client-level default timeout for this request, in seconds
"""
return await self._get(
- "/hooks/",
+ "/hooks",
options=make_request_options(
extra_headers=extra_headers,
extra_query=extra_query,
diff --git a/src/mobilerun/resources/tasks/tasks.py b/src/mobilerun/resources/tasks/tasks.py
index 73debb6..f409f2c 100644
--- a/src/mobilerun/resources/tasks/tasks.py
+++ b/src/mobilerun/resources/tasks/tasks.py
@@ -2,13 +2,13 @@
from __future__ import annotations
-from typing import Dict, Iterable, Optional
+from typing import Optional
from typing_extensions import Literal
import httpx
-from ...types import LlmModel, TaskStatus, task_run_params, task_list_params, task_run_streamed_params
-from ..._types import Body, Omit, Query, Headers, NoneType, NotGiven, SequenceNotStr, omit, not_given
+from ...types import TaskStatus, task_list_params
+from ..._types import Body, Omit, Query, Headers, NoneType, NotGiven, omit, not_given
from ..._utils import maybe_transform, async_maybe_transform
from ..._compat import cached_property
from .ui_states import (
@@ -35,7 +35,6 @@
AsyncScreenshotsResourceWithStreamingResponse,
)
from ..._base_client import make_request_options
-from ...types.llm_model import LlmModel
from ...types.task_status import TaskStatus
from ...types.task_run_response import TaskRunResponse
from ...types.task_list_response import TaskListResponse
@@ -144,7 +143,7 @@ def list(
timeout: Override the client-level default timeout for this request, in seconds
"""
return self._get(
- "/tasks/",
+ "/tasks",
options=make_request_options(
extra_headers=extra_headers,
extra_query=extra_query,
@@ -210,10 +209,8 @@ def get_status(
extra_body: Body | None = None,
timeout: float | httpx.Timeout | None | NotGiven = not_given,
) -> TaskGetStatusResponse:
- """Get the status of a task.
-
- If device is provided, return the status of the
- specific device. Otherwise, return the status of all devices.
+ """
+ Get the status of a task.
Args:
extra_headers: Send extra headers
@@ -270,20 +267,6 @@ def get_trajectory(
def run(
self,
*,
- llm_model: LlmModel,
- task: str,
- apps: SequenceNotStr[str] | Omit = omit,
- credentials: Iterable[task_run_params.Credential] | Omit = omit,
- device_id: Optional[str] | Omit = omit,
- display_id: int | Omit = omit,
- execution_timeout: int | Omit = omit,
- files: SequenceNotStr[str] | Omit = omit,
- max_steps: int | Omit = omit,
- output_schema: Optional[Dict[str, object]] | Omit = omit,
- reasoning: bool | Omit = omit,
- temperature: float | Omit = omit,
- vision: bool | Omit = omit,
- vpn_country: Optional[Literal["US", "BR", "FR", "DE", "IN", "JP", "KR", "ZA"]] | 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,
@@ -291,43 +274,9 @@ def run(
extra_body: Body | None = None,
timeout: float | httpx.Timeout | None | NotGiven = not_given,
) -> TaskRunResponse:
- """
- Run Task
-
- Args:
- device_id: The ID of the device to run the task on.
-
- display_id: The display ID of the device to run the task on.
-
- 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
- """
+ """Run Task"""
return self._post(
- "/tasks/",
- body=maybe_transform(
- {
- "llm_model": llm_model,
- "task": task,
- "apps": apps,
- "credentials": credentials,
- "device_id": device_id,
- "display_id": display_id,
- "execution_timeout": execution_timeout,
- "files": files,
- "max_steps": max_steps,
- "output_schema": output_schema,
- "reasoning": reasoning,
- "temperature": temperature,
- "vision": vision,
- "vpn_country": vpn_country,
- },
- task_run_params.TaskRunParams,
- ),
+ "/tasks",
options=make_request_options(
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
),
@@ -337,20 +286,6 @@ def run(
def run_streamed(
self,
*,
- llm_model: LlmModel,
- task: str,
- apps: SequenceNotStr[str] | Omit = omit,
- credentials: Iterable[task_run_streamed_params.Credential] | Omit = omit,
- device_id: Optional[str] | Omit = omit,
- display_id: int | Omit = omit,
- execution_timeout: int | Omit = omit,
- files: SequenceNotStr[str] | Omit = omit,
- max_steps: int | Omit = omit,
- output_schema: Optional[Dict[str, object]] | Omit = omit,
- reasoning: bool | Omit = omit,
- temperature: float | Omit = omit,
- vision: bool | Omit = omit,
- vpn_country: Optional[Literal["US", "BR", "FR", "DE", "IN", "JP", "KR", "ZA"]] | 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,
@@ -358,44 +293,10 @@ def run_streamed(
extra_body: Body | None = None,
timeout: float | httpx.Timeout | None | NotGiven = not_given,
) -> None:
- """
- Run Streamed Task
-
- Args:
- device_id: The ID of the device to run the task on.
-
- display_id: The display ID of the device to run the task on.
-
- 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
- """
+ """Run Streamed Task"""
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
return self._post(
"/tasks/stream",
- body=maybe_transform(
- {
- "llm_model": llm_model,
- "task": task,
- "apps": apps,
- "credentials": credentials,
- "device_id": device_id,
- "display_id": display_id,
- "execution_timeout": execution_timeout,
- "files": files,
- "max_steps": max_steps,
- "output_schema": output_schema,
- "reasoning": reasoning,
- "temperature": temperature,
- "vision": vision,
- "vpn_country": vpn_country,
- },
- task_run_streamed_params.TaskRunStreamedParams,
- ),
options=make_request_options(
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
),
@@ -533,7 +434,7 @@ async def list(
timeout: Override the client-level default timeout for this request, in seconds
"""
return await self._get(
- "/tasks/",
+ "/tasks",
options=make_request_options(
extra_headers=extra_headers,
extra_query=extra_query,
@@ -599,10 +500,8 @@ async def get_status(
extra_body: Body | None = None,
timeout: float | httpx.Timeout | None | NotGiven = not_given,
) -> TaskGetStatusResponse:
- """Get the status of a task.
-
- If device is provided, return the status of the
- specific device. Otherwise, return the status of all devices.
+ """
+ Get the status of a task.
Args:
extra_headers: Send extra headers
@@ -659,20 +558,6 @@ async def get_trajectory(
async def run(
self,
*,
- llm_model: LlmModel,
- task: str,
- apps: SequenceNotStr[str] | Omit = omit,
- credentials: Iterable[task_run_params.Credential] | Omit = omit,
- device_id: Optional[str] | Omit = omit,
- display_id: int | Omit = omit,
- execution_timeout: int | Omit = omit,
- files: SequenceNotStr[str] | Omit = omit,
- max_steps: int | Omit = omit,
- output_schema: Optional[Dict[str, object]] | Omit = omit,
- reasoning: bool | Omit = omit,
- temperature: float | Omit = omit,
- vision: bool | Omit = omit,
- vpn_country: Optional[Literal["US", "BR", "FR", "DE", "IN", "JP", "KR", "ZA"]] | 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,
@@ -680,43 +565,9 @@ async def run(
extra_body: Body | None = None,
timeout: float | httpx.Timeout | None | NotGiven = not_given,
) -> TaskRunResponse:
- """
- Run Task
-
- Args:
- device_id: The ID of the device to run the task on.
-
- display_id: The display ID of the device to run the task on.
-
- 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
- """
+ """Run Task"""
return await self._post(
- "/tasks/",
- body=await async_maybe_transform(
- {
- "llm_model": llm_model,
- "task": task,
- "apps": apps,
- "credentials": credentials,
- "device_id": device_id,
- "display_id": display_id,
- "execution_timeout": execution_timeout,
- "files": files,
- "max_steps": max_steps,
- "output_schema": output_schema,
- "reasoning": reasoning,
- "temperature": temperature,
- "vision": vision,
- "vpn_country": vpn_country,
- },
- task_run_params.TaskRunParams,
- ),
+ "/tasks",
options=make_request_options(
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
),
@@ -726,20 +577,6 @@ async def run(
async def run_streamed(
self,
*,
- llm_model: LlmModel,
- task: str,
- apps: SequenceNotStr[str] | Omit = omit,
- credentials: Iterable[task_run_streamed_params.Credential] | Omit = omit,
- device_id: Optional[str] | Omit = omit,
- display_id: int | Omit = omit,
- execution_timeout: int | Omit = omit,
- files: SequenceNotStr[str] | Omit = omit,
- max_steps: int | Omit = omit,
- output_schema: Optional[Dict[str, object]] | Omit = omit,
- reasoning: bool | Omit = omit,
- temperature: float | Omit = omit,
- vision: bool | Omit = omit,
- vpn_country: Optional[Literal["US", "BR", "FR", "DE", "IN", "JP", "KR", "ZA"]] | 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,
@@ -747,44 +584,10 @@ async def run_streamed(
extra_body: Body | None = None,
timeout: float | httpx.Timeout | None | NotGiven = not_given,
) -> None:
- """
- Run Streamed Task
-
- Args:
- device_id: The ID of the device to run the task on.
-
- display_id: The display ID of the device to run the task on.
-
- 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
- """
+ """Run Streamed Task"""
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
return await self._post(
"/tasks/stream",
- body=await async_maybe_transform(
- {
- "llm_model": llm_model,
- "task": task,
- "apps": apps,
- "credentials": credentials,
- "device_id": device_id,
- "display_id": display_id,
- "execution_timeout": execution_timeout,
- "files": files,
- "max_steps": max_steps,
- "output_schema": output_schema,
- "reasoning": reasoning,
- "temperature": temperature,
- "vision": vision,
- "vpn_country": vpn_country,
- },
- task_run_streamed_params.TaskRunStreamedParams,
- ),
options=make_request_options(
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
),
diff --git a/src/mobilerun/types/__init__.py b/src/mobilerun/types/__init__.py
index e5925bb..42ca86d 100644
--- a/src/mobilerun/types/__init__.py
+++ b/src/mobilerun/types/__init__.py
@@ -7,7 +7,6 @@
from .llm_model import LlmModel as LlmModel
from .task_status import TaskStatus as TaskStatus
from .app_list_params import AppListParams as AppListParams
-from .task_run_params import TaskRunParams as TaskRunParams
from .hook_list_params import HookListParams as HookListParams
from .task_list_params import TaskListParams as TaskListParams
from .app_list_response import AppListResponse as AppListResponse
@@ -20,14 +19,16 @@
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 .credential_list_params import CredentialListParams as CredentialListParams
from .hook_retrieve_response import HookRetrieveResponse as HookRetrieveResponse
from .task_retrieve_response import TaskRetrieveResponse as TaskRetrieveResponse
+from .device_terminate_params import DeviceTerminateParams as DeviceTerminateParams
from .hook_subscribe_response import HookSubscribeResponse as HookSubscribeResponse
from .credential_list_response import CredentialListResponse as CredentialListResponse
from .task_get_status_response import TaskGetStatusResponse as TaskGetStatusResponse
-from .task_run_streamed_params import TaskRunStreamedParams as TaskRunStreamedParams
from .hook_unsubscribe_response import HookUnsubscribeResponse as HookUnsubscribeResponse
from .task_get_trajectory_response import TaskGetTrajectoryResponse as TaskGetTrajectoryResponse
from .hook_get_sample_data_response import HookGetSampleDataResponse as HookGetSampleDataResponse
diff --git a/src/mobilerun/types/app_list_response.py b/src/mobilerun/types/app_list_response.py
index 8ddc700..2a928f8 100644
--- a/src/mobilerun/types/app_list_response.py
+++ b/src/mobilerun/types/app_list_response.py
@@ -1,7 +1,7 @@
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
from typing import List, Optional
-from datetime import date
+from datetime import datetime
from typing_extensions import Literal
from pydantic import Field as FieldInfo
@@ -285,7 +285,7 @@ class Item(BaseModel):
]
] = None
- created_at: Optional[date] = FieldInfo(alias="createdAt", default=None)
+ created_at: Optional[datetime] = FieldInfo(alias="createdAt", default=None)
description: Optional[str] = None
@@ -299,7 +299,9 @@ class Item(BaseModel):
package_name: str = FieldInfo(alias="packageName")
- queued_at: Optional[date] = FieldInfo(alias="queuedAt", default=None)
+ privacy_policy_url: Optional[str] = FieldInfo(alias="privacyPolicyUrl", default=None)
+
+ queued_at: Optional[datetime] = FieldInfo(alias="queuedAt", default=None)
rating_count: Optional[int] = FieldInfo(alias="ratingCount", default=None)
@@ -311,7 +313,7 @@ class Item(BaseModel):
target_sdk: Optional[int] = FieldInfo(alias="targetSdk", default=None)
- updated_at: Optional[date] = FieldInfo(alias="updatedAt", default=None)
+ updated_at: Optional[datetime] = FieldInfo(alias="updatedAt", default=None)
user_id: Optional[str] = FieldInfo(alias="userId", default=None)
diff --git a/src/mobilerun/types/credential_list_params.py b/src/mobilerun/types/credential_list_params.py
new file mode 100644
index 0000000..1f94a2e
--- /dev/null
+++ b/src/mobilerun/types/credential_list_params.py
@@ -0,0 +1,15 @@
+# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+from __future__ import annotations
+
+from typing_extensions import Annotated, TypedDict
+
+from .._utils import PropertyInfo
+
+__all__ = ["CredentialListParams"]
+
+
+class CredentialListParams(TypedDict, total=False):
+ page: int
+
+ page_size: Annotated[int, PropertyInfo(alias="pageSize")]
diff --git a/src/mobilerun/types/credential_list_response.py b/src/mobilerun/types/credential_list_response.py
index ec43392..b558f15 100644
--- a/src/mobilerun/types/credential_list_response.py
+++ b/src/mobilerun/types/credential_list_response.py
@@ -2,11 +2,29 @@
from typing import List
+from pydantic import Field as FieldInfo
+
from .._models import BaseModel
from .credentials.packages.credential import Credential
-__all__ = ["CredentialListResponse"]
+__all__ = ["CredentialListResponse", "Pagination"]
+
+
+class Pagination(BaseModel):
+ has_next: bool = FieldInfo(alias="hasNext")
+
+ has_prev: bool = FieldInfo(alias="hasPrev")
+
+ page: int
+
+ pages: int
+
+ page_size: int = FieldInfo(alias="pageSize")
+
+ total: int
class CredentialListResponse(BaseModel):
- data: List[Credential]
+ items: List[Credential]
+
+ pagination: Pagination
diff --git a/src/mobilerun/types/device.py b/src/mobilerun/types/device.py
index 3d570c1..9abc6c6 100644
--- a/src/mobilerun/types/device.py
+++ b/src/mobilerun/types/device.py
@@ -39,6 +39,8 @@ class Device(BaseModel):
task_count: int = FieldInfo(alias="taskCount")
+ terminates_at: Optional[datetime] = FieldInfo(alias="terminatesAt", default=None)
+
updated_at: datetime = FieldInfo(alias="updatedAt")
schema_: Optional[str] = FieldInfo(alias="$schema", default=None)
diff --git a/src/mobilerun/types/device_count_response.py b/src/mobilerun/types/device_count_response.py
new file mode 100644
index 0000000..966ff68
--- /dev/null
+++ b/src/mobilerun/types/device_count_response.py
@@ -0,0 +1,8 @@
+# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+from typing import Dict
+from typing_extensions import TypeAlias
+
+__all__ = ["DeviceCountResponse"]
+
+DeviceCountResponse: TypeAlias = Dict[str, int]
diff --git a/src/mobilerun/types/device_create_params.py b/src/mobilerun/types/device_create_params.py
index 4134f5a..6539a3b 100644
--- a/src/mobilerun/types/device_create_params.py
+++ b/src/mobilerun/types/device_create_params.py
@@ -13,11 +13,11 @@
class DeviceCreateParams(TypedDict, total=False):
device_type: Annotated[
- Literal["device_slot", "dedicated_emulated_device", "dedicated_physical_device"],
+ Literal["device_slot", "dedicated_emulated_device", "dedicated_physical_device", "dedicated_premium_device"],
PropertyInfo(alias="deviceType"),
]
- provider: Literal["limrun", "remote", "roidrun"]
+ provider: Literal["limrun", "physical", "premium", "roidrun"]
apps: Optional[SequenceNotStr[str]]
diff --git a/src/mobilerun/types/device_list_params.py b/src/mobilerun/types/device_list_params.py
index 48b5c7f..2e1503a 100644
--- a/src/mobilerun/types/device_list_params.py
+++ b/src/mobilerun/types/device_list_params.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+from typing import List, Optional
from typing_extensions import Literal, Annotated, TypedDict
from .._utils import PropertyInfo
@@ -24,6 +25,6 @@ class DeviceListParams(TypedDict, total=False):
provider: Literal["limrun", "personal", "remote", "roidrun"]
- state: Literal["creating", "assigned", "ready", "terminated", "unknown"]
+ state: Optional[List[Literal["creating", "assigned", "ready", "disconnected", "terminated", "unknown"]]]
type: Literal["device_slot", "dedicated_emulated_device", "dedicated_physical_device"]
diff --git a/src/mobilerun/types/device_terminate_params.py b/src/mobilerun/types/device_terminate_params.py
new file mode 100644
index 0000000..f4a7660
--- /dev/null
+++ b/src/mobilerun/types/device_terminate_params.py
@@ -0,0 +1,17 @@
+# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+from __future__ import annotations
+
+from typing import Union
+from datetime import datetime
+from typing_extensions import Annotated, TypedDict
+
+from .._utils import PropertyInfo
+
+__all__ = ["DeviceTerminateParams"]
+
+
+class DeviceTerminateParams(TypedDict, total=False):
+ previous_device_id: Annotated[str, PropertyInfo(alias="previousDeviceId")]
+
+ terminate_at: Annotated[Union[str, datetime], PropertyInfo(alias="terminateAt", format="iso8601")]
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/src/mobilerun/types/task.py b/src/mobilerun/types/task.py
index 0f79671..7b29244 100644
--- a/src/mobilerun/types/task.py
+++ b/src/mobilerun/types/task.py
@@ -36,6 +36,8 @@ class Task(BaseModel):
credentials: Optional[List[Credential]] = None
+ display_id: Optional[int] = FieldInfo(alias="displayId", default=None)
+
execution_timeout: Optional[int] = FieldInfo(alias="executionTimeout", default=None)
files: Optional[List[str]] = None
@@ -50,6 +52,8 @@ class Task(BaseModel):
reasoning: Optional[bool] = None
+ sandbox_id: Optional[str] = FieldInfo(alias="sandboxId", default=None)
+
status: Optional[TaskStatus] = None
steps: Optional[int] = None
@@ -58,6 +62,8 @@ class Task(BaseModel):
temperature: Optional[float] = None
+ tmp_device: Optional[bool] = FieldInfo(alias="tmpDevice", default=None)
+
trajectory: Optional[List[Dict[str, object]]] = None
updated_at: Optional[datetime] = FieldInfo(alias="updatedAt", default=None)
diff --git a/src/mobilerun/types/task_get_trajectory_response.py b/src/mobilerun/types/task_get_trajectory_response.py
index cac5d6f..e11150e 100644
--- a/src/mobilerun/types/task_get_trajectory_response.py
+++ b/src/mobilerun/types/task_get_trajectory_response.py
@@ -23,6 +23,7 @@
"TrajectoryTrajectoryFinalizeEventData",
"TrajectoryTrajectoryStopEvent",
"TrajectoryTrajectoryResultEvent",
+ "TrajectoryTrajectoryResultEventData",
"TrajectoryTrajectoryManagerInputEvent",
"TrajectoryTrajectoryManagerPlanEvent",
"TrajectoryTrajectoryManagerPlanEventData",
@@ -192,8 +193,28 @@ async def my_step(self, ctx: Context, ev: StartEvent) -> MyStopEv:
event: Literal["StopEvent"]
+class TrajectoryTrajectoryResultEventData(BaseModel):
+ """Lazy wrapper — avoids importing droidrun at module level.
+
+ The worker uses droidrun's ResultEvent directly; this model only
+ exists so the API OpenAPI schema can reference it without the heavy
+ droidrun import.
+ """
+
+ steps: Optional[int] = None
+
+ structured_output: Union[Dict[str, object], object, None] = None
+
+ success: Optional[bool] = None
+
+
class TrajectoryTrajectoryResultEvent(BaseModel):
- data: Dict[str, object]
+ data: TrajectoryTrajectoryResultEventData
+ """Lazy wrapper — avoids importing droidrun at module level.
+
+ The worker uses droidrun's ResultEvent directly; this model only exists so the
+ API OpenAPI schema can reference it without the heavy droidrun import.
+ """
event: Literal["ResultEvent"]
diff --git a/src/mobilerun/types/task_run_params.py b/src/mobilerun/types/task_run_params.py
deleted file mode 100644
index 81389c6..0000000
--- a/src/mobilerun/types/task_run_params.py
+++ /dev/null
@@ -1,52 +0,0 @@
-# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
-
-from __future__ import annotations
-
-from typing import Dict, Iterable, Optional
-from typing_extensions import Literal, Required, Annotated, TypedDict
-
-from .._types import SequenceNotStr
-from .._utils import PropertyInfo
-from .llm_model import LlmModel
-
-__all__ = ["TaskRunParams", "Credential"]
-
-
-class TaskRunParams(TypedDict, total=False):
- llm_model: Required[Annotated[LlmModel, PropertyInfo(alias="llmModel")]]
-
- task: Required[str]
-
- apps: SequenceNotStr[str]
-
- credentials: Iterable[Credential]
-
- device_id: Annotated[Optional[str], PropertyInfo(alias="deviceId")]
- """The ID of the device to run the task on."""
-
- display_id: Annotated[int, PropertyInfo(alias="displayId")]
- """The display ID of the device to run the task on."""
-
- execution_timeout: Annotated[int, PropertyInfo(alias="executionTimeout")]
-
- files: SequenceNotStr[str]
-
- max_steps: Annotated[int, PropertyInfo(alias="maxSteps")]
-
- output_schema: Annotated[Optional[Dict[str, object]], PropertyInfo(alias="outputSchema")]
-
- reasoning: bool
-
- temperature: float
-
- vision: bool
-
- vpn_country: Annotated[
- Optional[Literal["US", "BR", "FR", "DE", "IN", "JP", "KR", "ZA"]], PropertyInfo(alias="vpnCountry")
- ]
-
-
-class Credential(TypedDict, total=False):
- credential_names: Required[Annotated[SequenceNotStr[str], PropertyInfo(alias="credentialNames")]]
-
- package_name: Required[Annotated[str, PropertyInfo(alias="packageName")]]
diff --git a/src/mobilerun/types/task_run_streamed_params.py b/src/mobilerun/types/task_run_streamed_params.py
deleted file mode 100644
index b4975ad..0000000
--- a/src/mobilerun/types/task_run_streamed_params.py
+++ /dev/null
@@ -1,52 +0,0 @@
-# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
-
-from __future__ import annotations
-
-from typing import Dict, Iterable, Optional
-from typing_extensions import Literal, Required, Annotated, TypedDict
-
-from .._types import SequenceNotStr
-from .._utils import PropertyInfo
-from .llm_model import LlmModel
-
-__all__ = ["TaskRunStreamedParams", "Credential"]
-
-
-class TaskRunStreamedParams(TypedDict, total=False):
- llm_model: Required[Annotated[LlmModel, PropertyInfo(alias="llmModel")]]
-
- task: Required[str]
-
- apps: SequenceNotStr[str]
-
- credentials: Iterable[Credential]
-
- device_id: Annotated[Optional[str], PropertyInfo(alias="deviceId")]
- """The ID of the device to run the task on."""
-
- display_id: Annotated[int, PropertyInfo(alias="displayId")]
- """The display ID of the device to run the task on."""
-
- execution_timeout: Annotated[int, PropertyInfo(alias="executionTimeout")]
-
- files: SequenceNotStr[str]
-
- max_steps: Annotated[int, PropertyInfo(alias="maxSteps")]
-
- output_schema: Annotated[Optional[Dict[str, object]], PropertyInfo(alias="outputSchema")]
-
- reasoning: bool
-
- temperature: float
-
- vision: bool
-
- vpn_country: Annotated[
- Optional[Literal["US", "BR", "FR", "DE", "IN", "JP", "KR", "ZA"]], PropertyInfo(alias="vpnCountry")
- ]
-
-
-class Credential(TypedDict, total=False):
- credential_names: Required[Annotated[SequenceNotStr[str], PropertyInfo(alias="credentialNames")]]
-
- package_name: Required[Annotated[str, PropertyInfo(alias="packageName")]]
diff --git a/tests/api_resources/devices/test_apps.py b/tests/api_resources/devices/test_apps.py
index 6469d82..a54f742 100644
--- a/tests/api_resources/devices/test_apps.py
+++ b/tests/api_resources/devices/test_apps.py
@@ -17,6 +17,68 @@
class TestApps:
parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"])
+ @pytest.mark.skip(reason="Prism tests are disabled")
+ @parametrize
+ def test_method_update(self, client: Mobilerun) -> None:
+ app = client.devices.apps.update(
+ package_name="packageName",
+ device_id="deviceId",
+ )
+ assert app is None
+
+ @pytest.mark.skip(reason="Prism tests are disabled")
+ @parametrize
+ def test_method_update_with_all_params(self, client: Mobilerun) -> None:
+ app = client.devices.apps.update(
+ package_name="packageName",
+ device_id="deviceId",
+ x_device_display_id=0,
+ )
+ assert app is None
+
+ @pytest.mark.skip(reason="Prism tests are disabled")
+ @parametrize
+ def test_raw_response_update(self, client: Mobilerun) -> None:
+ response = client.devices.apps.with_raw_response.update(
+ package_name="packageName",
+ device_id="deviceId",
+ )
+
+ assert response.is_closed is True
+ assert response.http_request.headers.get("X-Stainless-Lang") == "python"
+ app = response.parse()
+ assert app is None
+
+ @pytest.mark.skip(reason="Prism tests are disabled")
+ @parametrize
+ def test_streaming_response_update(self, client: Mobilerun) -> None:
+ with client.devices.apps.with_streaming_response.update(
+ package_name="packageName",
+ device_id="deviceId",
+ ) as response:
+ assert not response.is_closed
+ assert response.http_request.headers.get("X-Stainless-Lang") == "python"
+
+ app = response.parse()
+ assert app is None
+
+ assert cast(Any, response.is_closed) is True
+
+ @pytest.mark.skip(reason="Prism tests are disabled")
+ @parametrize
+ def test_path_params_update(self, client: Mobilerun) -> None:
+ with pytest.raises(ValueError, match=r"Expected a non-empty value for `device_id` but received ''"):
+ client.devices.apps.with_raw_response.update(
+ package_name="packageName",
+ device_id="",
+ )
+
+ with pytest.raises(ValueError, match=r"Expected a non-empty value for `package_name` but received ''"):
+ client.devices.apps.with_raw_response.update(
+ package_name="",
+ device_id="deviceId",
+ )
+
@pytest.mark.skip(reason="Prism tests are disabled")
@parametrize
def test_method_list(self, client: Mobilerun) -> None:
@@ -256,6 +318,68 @@ class TestAsyncApps:
"async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"]
)
+ @pytest.mark.skip(reason="Prism tests are disabled")
+ @parametrize
+ async def test_method_update(self, async_client: AsyncMobilerun) -> None:
+ app = await async_client.devices.apps.update(
+ package_name="packageName",
+ device_id="deviceId",
+ )
+ assert app is None
+
+ @pytest.mark.skip(reason="Prism tests are disabled")
+ @parametrize
+ async def test_method_update_with_all_params(self, async_client: AsyncMobilerun) -> None:
+ app = await async_client.devices.apps.update(
+ package_name="packageName",
+ device_id="deviceId",
+ x_device_display_id=0,
+ )
+ assert app is None
+
+ @pytest.mark.skip(reason="Prism tests are disabled")
+ @parametrize
+ async def test_raw_response_update(self, async_client: AsyncMobilerun) -> None:
+ response = await async_client.devices.apps.with_raw_response.update(
+ package_name="packageName",
+ device_id="deviceId",
+ )
+
+ assert response.is_closed is True
+ assert response.http_request.headers.get("X-Stainless-Lang") == "python"
+ app = await response.parse()
+ assert app is None
+
+ @pytest.mark.skip(reason="Prism tests are disabled")
+ @parametrize
+ async def test_streaming_response_update(self, async_client: AsyncMobilerun) -> None:
+ async with async_client.devices.apps.with_streaming_response.update(
+ package_name="packageName",
+ device_id="deviceId",
+ ) as response:
+ assert not response.is_closed
+ assert response.http_request.headers.get("X-Stainless-Lang") == "python"
+
+ app = await response.parse()
+ assert app is None
+
+ assert cast(Any, response.is_closed) is True
+
+ @pytest.mark.skip(reason="Prism tests are disabled")
+ @parametrize
+ async def test_path_params_update(self, async_client: AsyncMobilerun) -> None:
+ with pytest.raises(ValueError, match=r"Expected a non-empty value for `device_id` but received ''"):
+ await async_client.devices.apps.with_raw_response.update(
+ package_name="packageName",
+ device_id="",
+ )
+
+ with pytest.raises(ValueError, match=r"Expected a non-empty value for `package_name` but received ''"):
+ await async_client.devices.apps.with_raw_response.update(
+ package_name="",
+ device_id="deviceId",
+ )
+
@pytest.mark.skip(reason="Prism tests are disabled")
@parametrize
async def test_method_list(self, async_client: AsyncMobilerun) -> None:
diff --git a/tests/api_resources/test_credentials.py b/tests/api_resources/test_credentials.py
index b488c4c..ae068c0 100644
--- a/tests/api_resources/test_credentials.py
+++ b/tests/api_resources/test_credentials.py
@@ -23,6 +23,15 @@ def test_method_list(self, client: Mobilerun) -> None:
credential = client.credentials.list()
assert_matches_type(CredentialListResponse, credential, path=["response"])
+ @pytest.mark.skip(reason="Prism tests are disabled")
+ @parametrize
+ def test_method_list_with_all_params(self, client: Mobilerun) -> None:
+ credential = client.credentials.list(
+ page=1,
+ page_size=1,
+ )
+ assert_matches_type(CredentialListResponse, credential, path=["response"])
+
@pytest.mark.skip(reason="Prism tests are disabled")
@parametrize
def test_raw_response_list(self, client: Mobilerun) -> None:
@@ -57,6 +66,15 @@ async def test_method_list(self, async_client: AsyncMobilerun) -> None:
credential = await async_client.credentials.list()
assert_matches_type(CredentialListResponse, credential, path=["response"])
+ @pytest.mark.skip(reason="Prism tests are disabled")
+ @parametrize
+ async def test_method_list_with_all_params(self, async_client: AsyncMobilerun) -> None:
+ credential = await async_client.credentials.list(
+ page=1,
+ page_size=1,
+ )
+ assert_matches_type(CredentialListResponse, credential, path=["response"])
+
@pytest.mark.skip(reason="Prism tests are disabled")
@parametrize
async def test_raw_response_list(self, async_client: AsyncMobilerun) -> None:
diff --git a/tests/api_resources/test_devices.py b/tests/api_resources/test_devices.py
index 0f3babb..b60998a 100644
--- a/tests/api_resources/test_devices.py
+++ b/tests/api_resources/test_devices.py
@@ -9,7 +9,12 @@
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,
+)
+from mobilerun._utils import parse_datetime
base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010")
@@ -123,7 +128,7 @@ def test_method_list_with_all_params(self, client: Mobilerun) -> None:
page=0,
page_size=0,
provider="limrun",
- state="creating",
+ state=["creating"],
type="device_slot",
)
assert_matches_type(DeviceListResponse, device, path=["response"])
@@ -150,11 +155,49 @@ 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:
device = client.devices.terminate(
- "deviceId",
+ device_id="deviceId",
+ )
+ assert device is None
+
+ @pytest.mark.skip(reason="Prism tests are disabled")
+ @parametrize
+ def test_method_terminate_with_all_params(self, client: Mobilerun) -> None:
+ device = client.devices.terminate(
+ device_id="deviceId",
+ previous_device_id="previousDeviceId",
+ terminate_at=parse_datetime("2019-12-27T18:11:19.117Z"),
)
assert device is None
@@ -162,7 +205,7 @@ def test_method_terminate(self, client: Mobilerun) -> None:
@parametrize
def test_raw_response_terminate(self, client: Mobilerun) -> None:
response = client.devices.with_raw_response.terminate(
- "deviceId",
+ device_id="deviceId",
)
assert response.is_closed is True
@@ -174,7 +217,7 @@ def test_raw_response_terminate(self, client: Mobilerun) -> None:
@parametrize
def test_streaming_response_terminate(self, client: Mobilerun) -> None:
with client.devices.with_streaming_response.terminate(
- "deviceId",
+ device_id="deviceId",
) as response:
assert not response.is_closed
assert response.http_request.headers.get("X-Stainless-Lang") == "python"
@@ -189,7 +232,7 @@ def test_streaming_response_terminate(self, client: Mobilerun) -> None:
def test_path_params_terminate(self, client: Mobilerun) -> None:
with pytest.raises(ValueError, match=r"Expected a non-empty value for `device_id` but received ''"):
client.devices.with_raw_response.terminate(
- "",
+ device_id="",
)
@pytest.mark.skip(reason="Prism tests are disabled")
@@ -346,7 +389,7 @@ async def test_method_list_with_all_params(self, async_client: AsyncMobilerun) -
page=0,
page_size=0,
provider="limrun",
- state="creating",
+ state=["creating"],
type="device_slot",
)
assert_matches_type(DeviceListResponse, device, path=["response"])
@@ -373,11 +416,49 @@ 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:
device = await async_client.devices.terminate(
- "deviceId",
+ device_id="deviceId",
+ )
+ assert device is None
+
+ @pytest.mark.skip(reason="Prism tests are disabled")
+ @parametrize
+ async def test_method_terminate_with_all_params(self, async_client: AsyncMobilerun) -> None:
+ device = await async_client.devices.terminate(
+ device_id="deviceId",
+ previous_device_id="previousDeviceId",
+ terminate_at=parse_datetime("2019-12-27T18:11:19.117Z"),
)
assert device is None
@@ -385,7 +466,7 @@ async def test_method_terminate(self, async_client: AsyncMobilerun) -> None:
@parametrize
async def test_raw_response_terminate(self, async_client: AsyncMobilerun) -> None:
response = await async_client.devices.with_raw_response.terminate(
- "deviceId",
+ device_id="deviceId",
)
assert response.is_closed is True
@@ -397,7 +478,7 @@ async def test_raw_response_terminate(self, async_client: AsyncMobilerun) -> Non
@parametrize
async def test_streaming_response_terminate(self, async_client: AsyncMobilerun) -> None:
async with async_client.devices.with_streaming_response.terminate(
- "deviceId",
+ device_id="deviceId",
) as response:
assert not response.is_closed
assert response.http_request.headers.get("X-Stainless-Lang") == "python"
@@ -412,7 +493,7 @@ async def test_streaming_response_terminate(self, async_client: AsyncMobilerun)
async def test_path_params_terminate(self, async_client: AsyncMobilerun) -> None:
with pytest.raises(ValueError, match=r"Expected a non-empty value for `device_id` but received ''"):
await async_client.devices.with_raw_response.terminate(
- "",
+ device_id="",
)
@pytest.mark.skip(reason="Prism tests are disabled")
diff --git a/tests/api_resources/test_tasks.py b/tests/api_resources/test_tasks.py
index d14af58..93873ee 100644
--- a/tests/api_resources/test_tasks.py
+++ b/tests/api_resources/test_tasks.py
@@ -236,45 +236,13 @@ def test_path_params_get_trajectory(self, client: Mobilerun) -> None:
@pytest.mark.skip(reason="Prism tests are disabled")
@parametrize
def test_method_run(self, client: Mobilerun) -> None:
- task = client.tasks.run(
- llm_model="openai/gpt-5",
- task="x",
- )
- assert_matches_type(TaskRunResponse, task, path=["response"])
-
- @pytest.mark.skip(reason="Prism tests are disabled")
- @parametrize
- def test_method_run_with_all_params(self, client: Mobilerun) -> None:
- task = client.tasks.run(
- llm_model="openai/gpt-5",
- task="x",
- apps=["string"],
- credentials=[
- {
- "credential_names": ["string"],
- "package_name": "packageName",
- }
- ],
- device_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e",
- display_id=0,
- execution_timeout=0,
- files=["string"],
- max_steps=0,
- output_schema={"foo": "bar"},
- reasoning=True,
- temperature=0,
- vision=True,
- vpn_country="US",
- )
+ task = client.tasks.run()
assert_matches_type(TaskRunResponse, task, path=["response"])
@pytest.mark.skip(reason="Prism tests are disabled")
@parametrize
def test_raw_response_run(self, client: Mobilerun) -> None:
- response = client.tasks.with_raw_response.run(
- llm_model="openai/gpt-5",
- task="x",
- )
+ response = client.tasks.with_raw_response.run()
assert response.is_closed is True
assert response.http_request.headers.get("X-Stainless-Lang") == "python"
@@ -284,10 +252,7 @@ def test_raw_response_run(self, client: Mobilerun) -> None:
@pytest.mark.skip(reason="Prism tests are disabled")
@parametrize
def test_streaming_response_run(self, client: Mobilerun) -> None:
- with client.tasks.with_streaming_response.run(
- llm_model="openai/gpt-5",
- task="x",
- ) as response:
+ with client.tasks.with_streaming_response.run() as response:
assert not response.is_closed
assert response.http_request.headers.get("X-Stainless-Lang") == "python"
@@ -299,45 +264,13 @@ def test_streaming_response_run(self, client: Mobilerun) -> None:
@pytest.mark.skip(reason="Prism tests are disabled")
@parametrize
def test_method_run_streamed(self, client: Mobilerun) -> None:
- task = client.tasks.run_streamed(
- llm_model="openai/gpt-5",
- task="x",
- )
- assert task is None
-
- @pytest.mark.skip(reason="Prism tests are disabled")
- @parametrize
- def test_method_run_streamed_with_all_params(self, client: Mobilerun) -> None:
- task = client.tasks.run_streamed(
- llm_model="openai/gpt-5",
- task="x",
- apps=["string"],
- credentials=[
- {
- "credential_names": ["string"],
- "package_name": "packageName",
- }
- ],
- device_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e",
- display_id=0,
- execution_timeout=0,
- files=["string"],
- max_steps=0,
- output_schema={"foo": "bar"},
- reasoning=True,
- temperature=0,
- vision=True,
- vpn_country="US",
- )
+ task = client.tasks.run_streamed()
assert task is None
@pytest.mark.skip(reason="Prism tests are disabled")
@parametrize
def test_raw_response_run_streamed(self, client: Mobilerun) -> None:
- response = client.tasks.with_raw_response.run_streamed(
- llm_model="openai/gpt-5",
- task="x",
- )
+ response = client.tasks.with_raw_response.run_streamed()
assert response.is_closed is True
assert response.http_request.headers.get("X-Stainless-Lang") == "python"
@@ -347,10 +280,7 @@ def test_raw_response_run_streamed(self, client: Mobilerun) -> None:
@pytest.mark.skip(reason="Prism tests are disabled")
@parametrize
def test_streaming_response_run_streamed(self, client: Mobilerun) -> None:
- with client.tasks.with_streaming_response.run_streamed(
- llm_model="openai/gpt-5",
- task="x",
- ) as response:
+ with client.tasks.with_streaming_response.run_streamed() as response:
assert not response.is_closed
assert response.http_request.headers.get("X-Stainless-Lang") == "python"
@@ -619,45 +549,13 @@ async def test_path_params_get_trajectory(self, async_client: AsyncMobilerun) ->
@pytest.mark.skip(reason="Prism tests are disabled")
@parametrize
async def test_method_run(self, async_client: AsyncMobilerun) -> None:
- task = await async_client.tasks.run(
- llm_model="openai/gpt-5",
- task="x",
- )
- assert_matches_type(TaskRunResponse, task, path=["response"])
-
- @pytest.mark.skip(reason="Prism tests are disabled")
- @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",
- task="x",
- apps=["string"],
- credentials=[
- {
- "credential_names": ["string"],
- "package_name": "packageName",
- }
- ],
- device_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e",
- display_id=0,
- execution_timeout=0,
- files=["string"],
- max_steps=0,
- output_schema={"foo": "bar"},
- reasoning=True,
- temperature=0,
- vision=True,
- vpn_country="US",
- )
+ task = await async_client.tasks.run()
assert_matches_type(TaskRunResponse, task, path=["response"])
@pytest.mark.skip(reason="Prism tests are disabled")
@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",
- task="x",
- )
+ response = await async_client.tasks.with_raw_response.run()
assert response.is_closed is True
assert response.http_request.headers.get("X-Stainless-Lang") == "python"
@@ -667,10 +565,7 @@ async def test_raw_response_run(self, async_client: AsyncMobilerun) -> None:
@pytest.mark.skip(reason="Prism tests are disabled")
@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",
- task="x",
- ) as response:
+ async with async_client.tasks.with_streaming_response.run() as response:
assert not response.is_closed
assert response.http_request.headers.get("X-Stainless-Lang") == "python"
@@ -682,45 +577,13 @@ async def test_streaming_response_run(self, async_client: AsyncMobilerun) -> Non
@pytest.mark.skip(reason="Prism tests are disabled")
@parametrize
async def test_method_run_streamed(self, async_client: AsyncMobilerun) -> None:
- task = await async_client.tasks.run_streamed(
- llm_model="openai/gpt-5",
- task="x",
- )
- assert task is None
-
- @pytest.mark.skip(reason="Prism tests are disabled")
- @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",
- task="x",
- apps=["string"],
- credentials=[
- {
- "credential_names": ["string"],
- "package_name": "packageName",
- }
- ],
- device_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e",
- display_id=0,
- execution_timeout=0,
- files=["string"],
- max_steps=0,
- output_schema={"foo": "bar"},
- reasoning=True,
- temperature=0,
- vision=True,
- vpn_country="US",
- )
+ task = await async_client.tasks.run_streamed()
assert task is None
@pytest.mark.skip(reason="Prism tests are disabled")
@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",
- task="x",
- )
+ response = await async_client.tasks.with_raw_response.run_streamed()
assert response.is_closed is True
assert response.http_request.headers.get("X-Stainless-Lang") == "python"
@@ -730,10 +593,7 @@ async def test_raw_response_run_streamed(self, async_client: AsyncMobilerun) ->
@pytest.mark.skip(reason="Prism tests are disabled")
@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",
- task="x",
- ) as response:
+ async with async_client.tasks.with_streaming_response.run_streamed() as response:
assert not response.is_closed
assert response.http_request.headers.get("X-Stainless-Lang") == "python"
diff --git a/tests/test_client.py b/tests/test_client.py
index add5de8..ac2d336 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):
@@ -743,7 +860,7 @@ def test_parse_retry_after_header(
@mock.patch("mobilerun._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
@pytest.mark.respx(base_url=base_url)
def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter, client: Mobilerun) -> None:
- respx_mock.get("/tasks/").mock(side_effect=httpx.TimeoutException("Test timeout error"))
+ respx_mock.get("/tasks").mock(side_effect=httpx.TimeoutException("Test timeout error"))
with pytest.raises(APITimeoutError):
client.tasks.with_streaming_response.list().__enter__()
@@ -753,7 +870,7 @@ def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter, clien
@mock.patch("mobilerun._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
@pytest.mark.respx(base_url=base_url)
def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter, client: Mobilerun) -> None:
- respx_mock.get("/tasks/").mock(return_value=httpx.Response(500))
+ respx_mock.get("/tasks").mock(return_value=httpx.Response(500))
with pytest.raises(APIStatusError):
client.tasks.with_streaming_response.list().__enter__()
@@ -783,7 +900,7 @@ def retry_handler(_request: httpx.Request) -> httpx.Response:
return httpx.Response(500)
return httpx.Response(200)
- respx_mock.get("/tasks/").mock(side_effect=retry_handler)
+ respx_mock.get("/tasks").mock(side_effect=retry_handler)
response = client.tasks.with_raw_response.list()
@@ -807,7 +924,7 @@ def retry_handler(_request: httpx.Request) -> httpx.Response:
return httpx.Response(500)
return httpx.Response(200)
- respx_mock.get("/tasks/").mock(side_effect=retry_handler)
+ respx_mock.get("/tasks").mock(side_effect=retry_handler)
response = client.tasks.with_raw_response.list(extra_headers={"x-stainless-retry-count": Omit()})
@@ -830,7 +947,7 @@ def retry_handler(_request: httpx.Request) -> httpx.Response:
return httpx.Response(500)
return httpx.Response(200)
- respx_mock.get("/tasks/").mock(side_effect=retry_handler)
+ respx_mock.get("/tasks").mock(side_effect=retry_handler)
response = client.tasks.with_raw_response.list(extra_headers={"x-stainless-retry-count": "42"})
@@ -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):
@@ -1586,7 +1769,7 @@ async def test_parse_retry_after_header(
async def test_retrying_timeout_errors_doesnt_leak(
self, respx_mock: MockRouter, async_client: AsyncMobilerun
) -> None:
- respx_mock.get("/tasks/").mock(side_effect=httpx.TimeoutException("Test timeout error"))
+ respx_mock.get("/tasks").mock(side_effect=httpx.TimeoutException("Test timeout error"))
with pytest.raises(APITimeoutError):
await async_client.tasks.with_streaming_response.list().__aenter__()
@@ -1598,7 +1781,7 @@ async def test_retrying_timeout_errors_doesnt_leak(
async def test_retrying_status_errors_doesnt_leak(
self, respx_mock: MockRouter, async_client: AsyncMobilerun
) -> None:
- respx_mock.get("/tasks/").mock(return_value=httpx.Response(500))
+ respx_mock.get("/tasks").mock(return_value=httpx.Response(500))
with pytest.raises(APIStatusError):
await async_client.tasks.with_streaming_response.list().__aenter__()
@@ -1628,7 +1811,7 @@ def retry_handler(_request: httpx.Request) -> httpx.Response:
return httpx.Response(500)
return httpx.Response(200)
- respx_mock.get("/tasks/").mock(side_effect=retry_handler)
+ respx_mock.get("/tasks").mock(side_effect=retry_handler)
response = await client.tasks.with_raw_response.list()
@@ -1652,7 +1835,7 @@ def retry_handler(_request: httpx.Request) -> httpx.Response:
return httpx.Response(500)
return httpx.Response(200)
- respx_mock.get("/tasks/").mock(side_effect=retry_handler)
+ respx_mock.get("/tasks").mock(side_effect=retry_handler)
response = await client.tasks.with_raw_response.list(extra_headers={"x-stainless-retry-count": Omit()})
@@ -1675,7 +1858,7 @@ def retry_handler(_request: httpx.Request) -> httpx.Response:
return httpx.Response(500)
return httpx.Response(200)
- respx_mock.get("/tasks/").mock(side_effect=retry_handler)
+ respx_mock.get("/tasks").mock(side_effect=retry_handler)
response = await client.tasks.with_raw_response.list(extra_headers={"x-stainless-retry-count": "42"})
diff --git a/tests/test_utils/test_json.py b/tests/test_utils/test_json.py
new file mode 100644
index 0000000..837420f
--- /dev/null
+++ b/tests/test_utils/test_json.py
@@ -0,0 +1,126 @@
+from __future__ import annotations
+
+import datetime
+from typing import Union
+
+import pydantic
+
+from mobilerun import _compat
+from mobilerun._utils._json import openapi_dumps
+
+
+class TestOpenapiDumps:
+ def test_basic(self) -> None:
+ data = {"key": "value", "number": 42}
+ json_bytes = openapi_dumps(data)
+ assert json_bytes == b'{"key":"value","number":42}'
+
+ def test_datetime_serialization(self) -> None:
+ dt = datetime.datetime(2023, 1, 1, 12, 0, 0)
+ data = {"datetime": dt}
+ json_bytes = openapi_dumps(data)
+ assert json_bytes == b'{"datetime":"2023-01-01T12:00:00"}'
+
+ def test_pydantic_model_serialization(self) -> None:
+ class User(pydantic.BaseModel):
+ first_name: str
+ last_name: str
+ age: int
+
+ model_instance = User(first_name="John", last_name="Kramer", age=83)
+ data = {"model": model_instance}
+ json_bytes = openapi_dumps(data)
+ assert json_bytes == b'{"model":{"first_name":"John","last_name":"Kramer","age":83}}'
+
+ def test_pydantic_model_with_default_values(self) -> None:
+ class User(pydantic.BaseModel):
+ name: str
+ role: str = "user"
+ active: bool = True
+ score: int = 0
+
+ model_instance = User(name="Alice")
+ data = {"model": model_instance}
+ json_bytes = openapi_dumps(data)
+ assert json_bytes == b'{"model":{"name":"Alice"}}'
+
+ def test_pydantic_model_with_default_values_overridden(self) -> None:
+ class User(pydantic.BaseModel):
+ name: str
+ role: str = "user"
+ active: bool = True
+
+ model_instance = User(name="Bob", role="admin", active=False)
+ data = {"model": model_instance}
+ json_bytes = openapi_dumps(data)
+ assert json_bytes == b'{"model":{"name":"Bob","role":"admin","active":false}}'
+
+ def test_pydantic_model_with_alias(self) -> None:
+ class User(pydantic.BaseModel):
+ first_name: str = pydantic.Field(alias="firstName")
+ last_name: str = pydantic.Field(alias="lastName")
+
+ model_instance = User(firstName="John", lastName="Doe")
+ data = {"model": model_instance}
+ json_bytes = openapi_dumps(data)
+ assert json_bytes == b'{"model":{"firstName":"John","lastName":"Doe"}}'
+
+ def test_pydantic_model_with_alias_and_default(self) -> None:
+ class User(pydantic.BaseModel):
+ user_name: str = pydantic.Field(alias="userName")
+ user_role: str = pydantic.Field(default="member", alias="userRole")
+ is_active: bool = pydantic.Field(default=True, alias="isActive")
+
+ model_instance = User(userName="charlie")
+ data = {"model": model_instance}
+ json_bytes = openapi_dumps(data)
+ assert json_bytes == b'{"model":{"userName":"charlie"}}'
+
+ model_with_overrides = User(userName="diana", userRole="admin", isActive=False)
+ data = {"model": model_with_overrides}
+ json_bytes = openapi_dumps(data)
+ assert json_bytes == b'{"model":{"userName":"diana","userRole":"admin","isActive":false}}'
+
+ def test_pydantic_model_with_nested_models_and_defaults(self) -> None:
+ class Address(pydantic.BaseModel):
+ street: str
+ city: str = "Unknown"
+
+ class User(pydantic.BaseModel):
+ name: str
+ address: Address
+ verified: bool = False
+
+ if _compat.PYDANTIC_V1:
+ # to handle forward references in Pydantic v1
+ User.update_forward_refs(**locals()) # type: ignore[reportDeprecated]
+
+ address = Address(street="123 Main St")
+ user = User(name="Diana", address=address)
+ data = {"user": user}
+ json_bytes = openapi_dumps(data)
+ assert json_bytes == b'{"user":{"name":"Diana","address":{"street":"123 Main St"}}}'
+
+ address_with_city = Address(street="456 Oak Ave", city="Boston")
+ user_verified = User(name="Eve", address=address_with_city, verified=True)
+ data = {"user": user_verified}
+ json_bytes = openapi_dumps(data)
+ assert (
+ json_bytes == b'{"user":{"name":"Eve","address":{"street":"456 Oak Ave","city":"Boston"},"verified":true}}'
+ )
+
+ def test_pydantic_model_with_optional_fields(self) -> None:
+ class User(pydantic.BaseModel):
+ name: str
+ email: Union[str, None]
+ phone: Union[str, None]
+
+ model_with_none = User(name="Eve", email=None, phone=None)
+ data = {"model": model_with_none}
+ json_bytes = openapi_dumps(data)
+ assert json_bytes == b'{"model":{"name":"Eve","email":null,"phone":null}}'
+
+ model_with_values = User(name="Frank", email="frank@example.com", phone=None)
+ data = {"model": model_with_values}
+ json_bytes = openapi_dumps(data)
+ assert json_bytes == b'{"model":{"name":"Frank","email":"frank@example.com","phone":null}}'