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. -[![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en-US/install-mcp?name=mobilerun-mcp&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIm1vYmlsZXJ1bi1tY3AiXX0) -[![Install in VS Code](https://img.shields.io/badge/_-Add_to_VS_Code-blue?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCA0MCA0MCI+PHBhdGggZmlsbD0iI0VFRSIgZmlsbC1ydWxlPSJldmVub2RkIiBkPSJNMzAuMjM1IDM5Ljg4NGEyLjQ5MSAyLjQ5MSAwIDAgMS0xLjc4MS0uNzNMMTIuNyAyNC43OGwtMy40NiAyLjYyNC0zLjQwNiAyLjU4MmExLjY2NSAxLjY2NSAwIDAgMS0xLjA4Mi4zMzggMS42NjQgMS42NjQgMCAwIDEtMS4wNDYtLjQzMWwtMi4yLTJhMS42NjYgMS42NjYgMCAwIDEgMC0yLjQ2M0w3LjQ1OCAyMCA0LjY3IDE3LjQ1MyAxLjUwNyAxNC41N2ExLjY2NSAxLjY2NSAwIDAgMSAwLTIuNDYzbDIuMi0yYTEuNjY1IDEuNjY1IDAgMCAxIDIuMTMtLjA5N2w2Ljg2MyA1LjIwOUwyOC40NTIuODQ0YTIuNDg4IDIuNDg4IDAgMCAxIDEuODQxLS43MjljLjM1MS4wMDkuNjk5LjA5MSAxLjAxOS4yNDVsOC4yMzYgMy45NjFhMi41IDIuNSAwIDAgMSAxLjQxNSAyLjI1M3YuMDk5LS4wNDVWMzMuMzd2LS4wNDUuMDk1YTIuNTAxIDIuNTAxIDAgMCAxLTEuNDE2IDIuMjU3bC04LjIzNSAzLjk2MWEyLjQ5MiAyLjQ5MiAwIDAgMS0xLjA3Ny4yNDZabS43MTYtMjguOTQ3LTExLjk0OCA5LjA2MiAxMS45NTIgOS4wNjUtLjAwNC0xOC4xMjdaIi8+PC9zdmc+)](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) +[![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en-US/install-mcp?name=mobilerun-mcp&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIm1vYmlsZXJ1bi1tY3AiXSwiZW52Ijp7Ik1PQklMRVJVTl9DTE9VRF9BUElfS0VZIjoiTXkgQVBJIEtleSJ9fQ) +[![Install in VS Code](https://img.shields.io/badge/_-Add_to_VS_Code-blue?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCA0MCA0MCI+PHBhdGggZmlsbD0iI0VFRSIgZmlsbC1ydWxlPSJldmVub2RkIiBkPSJNMzAuMjM1IDM5Ljg4NGEyLjQ5MSAyLjQ5MSAwIDAgMS0xLjc4MS0uNzNMMTIuNyAyNC43OGwtMy40NiAyLjYyNC0zLjQwNiAyLjU4MmExLjY2NSAxLjY2NSAwIDAgMS0xLjA4Mi4zMzggMS42NjQgMS42NjQgMCAwIDEtMS4wNDYtLjQzMWwtMi4yLTJhMS42NjYgMS42NjYgMCAwIDEgMC0yLjQ2M0w3LjQ1OCAyMCA0LjY3IDE3LjQ1MyAxLjUwNyAxNC41N2ExLjY2NSAxLjY2NSAwIDAgMSAwLTIuNDYzbDIuMi0yYTEuNjY1IDEuNjY1IDAgMCAxIDIuMTMtLjA5N2w2Ljg2MyA1LjIwOUwyOC40NTIuODQ0YTIuNDg4IDIuNDg4IDAgMCAxIDEuODQxLS43MjljLjM1MS4wMDkuNjk5LjA5MSAxLjAxOS4yNDVsOC4yMzYgMy45NjFhMi41IDIuNSAwIDAgMSAxLjQxNSAyLjI1M3YuMDk5LS4wNDVWMzMuMzd2LS4wNDUuMDk1YTIuNTAxIDIuNTAxIDAgMCAxLTEuNDE2IDIuMjU3bC04LjIzNSAzLjk2MWEyLjQ5MiAyLjQ5MiAwIDAgMS0xLjA3Ny4yNDZabS43MTYtMjguOTQ3LTExLjk0OCA5LjA2MiAxMS45NTIgOS4wNjUtLjAwNC0xOC4xMjdaIi8+PC9zdmc+)](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}}'