diff --git a/.release-please-manifest.json b/.release-please-manifest.json index e0dc500..1f73031 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "3.1.0" + ".": "3.2.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 1578830..02711b4 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 89 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/droidrun%2Fdroidrun-cloud-5354e1e393f7a2c470fba288fd927027d7f8ab0f76350be330392a32d61321d7.yml -openapi_spec_hash: 61d176c5697051a52251e67cc2a143b7 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/droidrun%2Fdroidrun-cloud-59c3ac5792e71fda5cbe685c5e559adbc0cd1e18638e88c6480a88ac0f1d7c43.yml +openapi_spec_hash: d540aaa4fcea15924d55d6f862066192 config_hash: 2e5f796057a879ad2efd58b3ec8289cf diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b66e4c..042a50c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## 3.2.0 (2026-04-29) + +Full Changelog: [v3.1.0...v3.2.0](https://github.com/droidrun/mobilerun-sdk-python/compare/v3.1.0...v3.2.0) + +### Features + +* **api:** api update ([75bbc88](https://github.com/droidrun/mobilerun-sdk-python/commit/75bbc88550c6f53e16c25c692ea0febc2ed07acd)) +* support setting headers via env ([273abbc](https://github.com/droidrun/mobilerun-sdk-python/commit/273abbc5339e39f57f59d885a9bfdf5df5d2e4fe)) + + +### Bug Fixes + +* use correct field name format for multipart file arrays ([7ad94ce](https://github.com/droidrun/mobilerun-sdk-python/commit/7ad94ce83e73be25a9dc55c91aabe36065594480)) + + +### Performance Improvements + +* **client:** optimize file structure copying in multipart requests ([b6562d3](https://github.com/droidrun/mobilerun-sdk-python/commit/b6562d3c3c02a73d9e7e36f26bfd6d86728993d2)) + + +### Chores + +* **internal:** more robust bootstrap script ([d8ed809](https://github.com/droidrun/mobilerun-sdk-python/commit/d8ed809f6aec7556e96cd697a8b517120cd4ebff)) + ## 3.1.0 (2026-04-16) Full Changelog: [v3.0.0...v3.1.0](https://github.com/droidrun/mobilerun-sdk-python/compare/v3.0.0...v3.1.0) diff --git a/pyproject.toml b/pyproject.toml index 4a1ba0a..c601562 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mobilerun-sdk" -version = "3.1.0" +version = "3.2.0" description = "The official Python library for the mobilerun API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/scripts/bootstrap b/scripts/bootstrap index b430fee..fe8451e 100755 --- a/scripts/bootstrap +++ b/scripts/bootstrap @@ -4,7 +4,7 @@ set -e cd "$(dirname "$0")/.." -if [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ] && [ "$SKIP_BREW" != "1" ] && [ -t 0 ]; then +if [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ] && [ "${SKIP_BREW:-}" != "1" ] && [ -t 0 ]; then brew bundle check >/dev/null 2>&1 || { echo -n "==> Install Homebrew dependencies? (y/N): " read -r response diff --git a/src/mobilerun_sdk/_client.py b/src/mobilerun_sdk/_client.py index 09cae30..85eb634 100644 --- a/src/mobilerun_sdk/_client.py +++ b/src/mobilerun_sdk/_client.py @@ -20,7 +20,11 @@ RequestOptions, not_given, ) -from ._utils import is_given, get_async_library +from ._utils import ( + is_given, + is_mapping_t, + get_async_library, +) from ._compat import cached_property from ._version import __version__ from ._streaming import Stream as Stream, AsyncStream as AsyncStream @@ -96,6 +100,15 @@ def __init__( if base_url is None: base_url = f"https://api.mobilerun.ai/v1" + custom_headers_env = os.environ.get("MOBILERUN_CUSTOM_HEADERS") + if custom_headers_env is not None: + parsed: dict[str, str] = {} + for line in custom_headers_env.split("\n"): + colon = line.find(":") + if colon >= 0: + parsed[line[:colon].strip()] = line[colon + 1 :].strip() + default_headers = {**parsed, **(default_headers if is_mapping_t(default_headers) else {})} + super().__init__( version=__version__, base_url=base_url, @@ -334,6 +347,15 @@ def __init__( if base_url is None: base_url = f"https://api.mobilerun.ai/v1" + custom_headers_env = os.environ.get("MOBILERUN_CUSTOM_HEADERS") + if custom_headers_env is not None: + parsed: dict[str, str] = {} + for line in custom_headers_env.split("\n"): + colon = line.find(":") + if colon >= 0: + parsed[line[:colon].strip()] = line[colon + 1 :].strip() + default_headers = {**parsed, **(default_headers if is_mapping_t(default_headers) else {})} + super().__init__( version=__version__, base_url=base_url, diff --git a/src/mobilerun_sdk/_files.py b/src/mobilerun_sdk/_files.py index 0f0f150..488b349 100644 --- a/src/mobilerun_sdk/_files.py +++ b/src/mobilerun_sdk/_files.py @@ -3,8 +3,8 @@ import io import os import pathlib -from typing import overload -from typing_extensions import TypeGuard +from typing import Sequence, cast, overload +from typing_extensions import TypeVar, TypeGuard import anyio @@ -17,7 +17,9 @@ HttpxFileContent, HttpxRequestFiles, ) -from ._utils import is_tuple_t, is_mapping_t, is_sequence_t +from ._utils import is_list, is_mapping, is_tuple_t, is_mapping_t, is_sequence_t + +_T = TypeVar("_T") def is_base64_file_input(obj: object) -> TypeGuard[Base64FileInput]: @@ -121,3 +123,51 @@ async def async_read_file_content(file: FileContent) -> HttpxFileContent: return await anyio.Path(file).read_bytes() return file + + +def deepcopy_with_paths(item: _T, paths: Sequence[Sequence[str]]) -> _T: + """Copy only the containers along the given paths. + + Used to guard against mutation by extract_files without copying the entire structure. + Only dicts and lists that lie on a path are copied; everything else + is returned by reference. + + For example, given paths=[["foo", "files", "file"]] and the structure: + { + "foo": { + "bar": {"baz": {}}, + "files": {"file": } + } + } + The root dict, "foo", and "files" are copied (they lie on the path). + "bar" and "baz" are returned by reference (off the path). + """ + return _deepcopy_with_paths(item, paths, 0) + + +def _deepcopy_with_paths(item: _T, paths: Sequence[Sequence[str]], index: int) -> _T: + if not paths: + return item + if is_mapping(item): + key_to_paths: dict[str, list[Sequence[str]]] = {} + for path in paths: + if index < len(path): + key_to_paths.setdefault(path[index], []).append(path) + + # if no path continues through this mapping, it won't be mutated and copying it is redundant + if not key_to_paths: + return item + + result = dict(item) + for key, subpaths in key_to_paths.items(): + if key in result: + result[key] = _deepcopy_with_paths(result[key], subpaths, index + 1) + return cast(_T, result) + if is_list(item): + array_paths = [path for path in paths if index < len(path) and path[index] == ""] + + # if no path expects a list here, nothing will be mutated inside it - return by reference + if not array_paths: + return cast(_T, item) + return cast(_T, [_deepcopy_with_paths(entry, array_paths, index + 1) for entry in item]) + return item diff --git a/src/mobilerun_sdk/_qs.py b/src/mobilerun_sdk/_qs.py index de8c99b..4127c19 100644 --- a/src/mobilerun_sdk/_qs.py +++ b/src/mobilerun_sdk/_qs.py @@ -2,17 +2,13 @@ from typing import Any, List, Tuple, Union, Mapping, TypeVar from urllib.parse import parse_qs, urlencode -from typing_extensions import Literal, get_args +from typing_extensions import get_args -from ._types import NotGiven, not_given +from ._types import NotGiven, ArrayFormat, NestedFormat, not_given from ._utils import flatten _T = TypeVar("_T") - -ArrayFormat = Literal["comma", "repeat", "indices", "brackets"] -NestedFormat = Literal["dots", "brackets"] - PrimitiveData = Union[str, int, float, bool, None] # this should be Data = Union[PrimitiveData, "List[Data]", "Tuple[Data]", "Mapping[str, Data]"] # https://github.com/microsoft/pyright/issues/3555 diff --git a/src/mobilerun_sdk/_types.py b/src/mobilerun_sdk/_types.py index 0f64915..c41c84a 100644 --- a/src/mobilerun_sdk/_types.py +++ b/src/mobilerun_sdk/_types.py @@ -47,6 +47,9 @@ ModelT = TypeVar("ModelT", bound=pydantic.BaseModel) _T = TypeVar("_T") +ArrayFormat = Literal["comma", "repeat", "indices", "brackets"] +NestedFormat = Literal["dots", "brackets"] + # Approximates httpx internal ProxiesTypes and RequestFiles types # while adding support for `PathLike` instances diff --git a/src/mobilerun_sdk/_utils/__init__.py b/src/mobilerun_sdk/_utils/__init__.py index 10cb66d..1c090e5 100644 --- a/src/mobilerun_sdk/_utils/__init__.py +++ b/src/mobilerun_sdk/_utils/__init__.py @@ -24,7 +24,6 @@ coerce_integer as coerce_integer, file_from_path as file_from_path, strip_not_given as strip_not_given, - deepcopy_minimal as deepcopy_minimal, get_async_library as get_async_library, maybe_coerce_float as maybe_coerce_float, get_required_header as get_required_header, diff --git a/src/mobilerun_sdk/_utils/_utils.py b/src/mobilerun_sdk/_utils/_utils.py index 63b8cd6..199cd23 100644 --- a/src/mobilerun_sdk/_utils/_utils.py +++ b/src/mobilerun_sdk/_utils/_utils.py @@ -17,11 +17,11 @@ ) from pathlib import Path from datetime import date, datetime -from typing_extensions import TypeGuard +from typing_extensions import TypeGuard, get_args import sniffio -from .._types import Omit, NotGiven, FileTypes, HeadersLike +from .._types import Omit, NotGiven, FileTypes, ArrayFormat, HeadersLike _T = TypeVar("_T") _TupleT = TypeVar("_TupleT", bound=Tuple[object, ...]) @@ -40,25 +40,45 @@ def extract_files( query: Mapping[str, object], *, paths: Sequence[Sequence[str]], + array_format: ArrayFormat = "brackets", ) -> list[tuple[str, FileTypes]]: """Recursively extract files from the given dictionary based on specified paths. A path may look like this ['foo', 'files', '', 'data']. + ``array_format`` controls how ```` segments contribute to the emitted + field name. Supported values: ``"brackets"`` (``foo[]``), ``"repeat"`` and + ``"comma"`` (``foo``), ``"indices"`` (``foo[0]``, ``foo[1]``). + Note: this mutates the given dictionary. """ files: list[tuple[str, FileTypes]] = [] for path in paths: - files.extend(_extract_items(query, path, index=0, flattened_key=None)) + files.extend(_extract_items(query, path, index=0, flattened_key=None, array_format=array_format)) return files +def _array_suffix(array_format: ArrayFormat, array_index: int) -> str: + if array_format == "brackets": + return "[]" + if array_format == "indices": + return f"[{array_index}]" + if array_format == "repeat" or array_format == "comma": + # Both repeat the bare field name for each file part; there is no + # meaningful way to comma-join binary parts. + return "" + raise NotImplementedError( + f"Unknown array_format value: {array_format}, choose from {', '.join(get_args(ArrayFormat))}" + ) + + def _extract_items( obj: object, path: Sequence[str], *, index: int, flattened_key: str | None, + array_format: ArrayFormat, ) -> list[tuple[str, FileTypes]]: try: key = path[index] @@ -75,9 +95,11 @@ def _extract_items( if is_list(obj): files: list[tuple[str, FileTypes]] = [] - for entry in obj: - assert_is_file_content(entry, key=flattened_key + "[]" if flattened_key else "") - files.append((flattened_key + "[]", cast(FileTypes, entry))) + for array_index, entry in enumerate(obj): + suffix = _array_suffix(array_format, array_index) + emitted_key = (flattened_key + suffix) if flattened_key else suffix + assert_is_file_content(entry, key=emitted_key) + files.append((emitted_key, cast(FileTypes, entry))) return files assert_is_file_content(obj, key=flattened_key) @@ -106,6 +128,7 @@ def _extract_items( path, index=index, flattened_key=flattened_key, + array_format=array_format, ) elif is_list(obj): if key != "": @@ -117,9 +140,12 @@ def _extract_items( item, path, index=index, - flattened_key=flattened_key + "[]" if flattened_key is not None else "[]", + flattened_key=( + (flattened_key if flattened_key is not None else "") + _array_suffix(array_format, array_index) + ), + array_format=array_format, ) - for item in obj + for array_index, item in enumerate(obj) ] ) @@ -177,21 +203,6 @@ def is_iterable(obj: object) -> TypeGuard[Iterable[object]]: return isinstance(obj, Iterable) -def deepcopy_minimal(item: _T) -> _T: - """Minimal reimplementation of copy.deepcopy() that will only copy certain object types: - - - mappings, e.g. `dict` - - list - - This is done for performance reasons. - """ - if is_mapping(item): - return cast(_T, {k: deepcopy_minimal(v) for k, v in item.items()}) - if is_list(item): - return cast(_T, [deepcopy_minimal(entry) for entry in item]) - return item - - # copied from https://github.com/Rapptz/RoboDanny def human_join(seq: Sequence[str], *, delim: str = ", ", final: str = "or") -> str: size = len(seq) diff --git a/src/mobilerun_sdk/_version.py b/src/mobilerun_sdk/_version.py index f3f6069..a3beadb 100644 --- a/src/mobilerun_sdk/_version.py +++ b/src/mobilerun_sdk/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "mobilerun_sdk" -__version__ = "3.1.0" # x-release-please-version +__version__ = "3.2.0" # x-release-please-version diff --git a/src/mobilerun_sdk/resources/devices/devices.py b/src/mobilerun_sdk/resources/devices/devices.py index b6743a9..0ef9278 100644 --- a/src/mobilerun_sdk/resources/devices/devices.py +++ b/src/mobilerun_sdk/resources/devices/devices.py @@ -196,16 +196,22 @@ def with_streaming_response(self) -> DevicesResourceWithStreamingResponse: def create( self, *, + query_country: str | Omit = omit, device_type: Literal[ "dedicated_physical_device", "dedicated_premium_device", "dedicated_emulated_device", "dedicated_ios_device" ] | Omit = omit, + android_version: int | Omit = omit, apps: Optional[SequenceNotStr[str]] | Omit = omit, carrier: DeviceCarrier | Omit = omit, + body_country: str | Omit = omit, files: Optional[SequenceNotStr[str]] | Omit = omit, identifiers: DeviceIdentifiers | Omit = omit, + locale: str | Omit = omit, + location: device_create_params.Location | Omit = omit, name: str | Omit = omit, proxy: device_create_params.Proxy | Omit = omit, + timezone: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -213,10 +219,14 @@ def create( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> Device: - """ - Provision a new device + """Provision a new device Args: + query_country: ISO 3166-1 alpha-2 country code. + + If omitted the system picks the country with + the most availability. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -229,12 +239,17 @@ def create( "/devices", body=maybe_transform( { + "android_version": android_version, "apps": apps, "carrier": carrier, + "body_country": body_country, "files": files, "identifiers": identifiers, + "locale": locale, + "location": location, "name": name, "proxy": proxy, + "timezone": timezone, }, device_create_params.DeviceCreateParams, ), @@ -243,7 +258,13 @@ def create( extra_query=extra_query, extra_body=extra_body, timeout=timeout, - query=maybe_transform({"device_type": device_type}, device_create_params.DeviceCreateParams), + query=maybe_transform( + { + "query_country": query_country, + "device_type": device_type, + }, + device_create_params.DeviceCreateParams, + ), ), cast_to=Device, ) @@ -549,16 +570,22 @@ def with_streaming_response(self) -> AsyncDevicesResourceWithStreamingResponse: async def create( self, *, + query_country: str | Omit = omit, device_type: Literal[ "dedicated_physical_device", "dedicated_premium_device", "dedicated_emulated_device", "dedicated_ios_device" ] | Omit = omit, + android_version: int | Omit = omit, apps: Optional[SequenceNotStr[str]] | Omit = omit, carrier: DeviceCarrier | Omit = omit, + body_country: str | Omit = omit, files: Optional[SequenceNotStr[str]] | Omit = omit, identifiers: DeviceIdentifiers | Omit = omit, + locale: str | Omit = omit, + location: device_create_params.Location | Omit = omit, name: str | Omit = omit, proxy: device_create_params.Proxy | Omit = omit, + timezone: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -566,10 +593,14 @@ async def create( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> Device: - """ - Provision a new device + """Provision a new device Args: + query_country: ISO 3166-1 alpha-2 country code. + + If omitted the system picks the country with + the most availability. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -582,12 +613,17 @@ async def create( "/devices", body=await async_maybe_transform( { + "android_version": android_version, "apps": apps, "carrier": carrier, + "body_country": body_country, "files": files, "identifiers": identifiers, + "locale": locale, + "location": location, "name": name, "proxy": proxy, + "timezone": timezone, }, device_create_params.DeviceCreateParams, ), @@ -597,7 +633,11 @@ async def create( extra_body=extra_body, timeout=timeout, query=await async_maybe_transform( - {"device_type": device_type}, device_create_params.DeviceCreateParams + { + "query_country": query_country, + "device_type": device_type, + }, + device_create_params.DeviceCreateParams, ), ), cast_to=Device, diff --git a/src/mobilerun_sdk/resources/devices/files.py b/src/mobilerun_sdk/resources/devices/files.py index 0b20330..ffaba39 100644 --- a/src/mobilerun_sdk/resources/devices/files.py +++ b/src/mobilerun_sdk/resources/devices/files.py @@ -6,16 +6,9 @@ import httpx +from ..._files import deepcopy_with_paths from ..._types import Body, Omit, Query, Headers, NoneType, NotGiven, FileTypes, omit, not_given -from ..._utils import ( - is_given, - extract_files, - path_template, - maybe_transform, - strip_not_given, - deepcopy_minimal, - async_maybe_transform, -) +from ..._utils import is_given, extract_files, path_template, maybe_transform, strip_not_given, async_maybe_transform from ..._compat import cached_property from ..._resource import SyncAPIResource, AsyncAPIResource from ..._response import ( @@ -222,7 +215,7 @@ def upload( ), **(extra_headers or {}), } - body = deepcopy_minimal({"file": file}) + body = deepcopy_with_paths({"file": file}, [["file"]]) files = extract_files(cast(Mapping[str, object], body), paths=[["file"]]) # It should be noted that the actual Content-Type header that will be # sent to the server will contain a `boundary` parameter, e.g. @@ -434,7 +427,7 @@ async def upload( ), **(extra_headers or {}), } - body = deepcopy_minimal({"file": file}) + body = deepcopy_with_paths({"file": file}, [["file"]]) files = extract_files(cast(Mapping[str, object], body), paths=[["file"]]) # It should be noted that the actual Content-Type header that will be # sent to the server will contain a `boundary` parameter, e.g. diff --git a/src/mobilerun_sdk/resources/devices/keyboard.py b/src/mobilerun_sdk/resources/devices/keyboard.py index 66c14c6..97e2695 100644 --- a/src/mobilerun_sdk/resources/devices/keyboard.py +++ b/src/mobilerun_sdk/resources/devices/keyboard.py @@ -130,6 +130,7 @@ def write( *, text: str, clear: bool | Omit = omit, + error_rate: float | Omit = omit, stealth: bool | Omit = omit, wpm: int | Omit = omit, x_device_display_id: int | Omit = omit, @@ -143,9 +144,11 @@ def write( """Input text Args: - wpm: Words per minute for stealth typing. + error_rate: Per-character mistake rate for humantouch typing. - 0 uses portal default. + -1 uses server default. + + wpm: Words per minute for stealth typing. 0 uses portal default. extra_headers: Send extra headers @@ -170,6 +173,7 @@ def write( { "text": text, "clear": clear, + "error_rate": error_rate, "stealth": stealth, "wpm": wpm, }, @@ -292,6 +296,7 @@ async def write( *, text: str, clear: bool | Omit = omit, + error_rate: float | Omit = omit, stealth: bool | Omit = omit, wpm: int | Omit = omit, x_device_display_id: int | Omit = omit, @@ -305,9 +310,11 @@ async def write( """Input text Args: - wpm: Words per minute for stealth typing. + error_rate: Per-character mistake rate for humantouch typing. + + -1 uses server default. - 0 uses portal default. + wpm: Words per minute for stealth typing. 0 uses portal default. extra_headers: Send extra headers @@ -332,6 +339,7 @@ async def write( { "text": text, "clear": clear, + "error_rate": error_rate, "stealth": stealth, "wpm": wpm, }, diff --git a/src/mobilerun_sdk/resources/devices/proxy.py b/src/mobilerun_sdk/resources/devices/proxy.py index 5cf46cb..08e0c04 100644 --- a/src/mobilerun_sdk/resources/devices/proxy.py +++ b/src/mobilerun_sdk/resources/devices/proxy.py @@ -52,7 +52,6 @@ def connect( smart_ip: bool | Omit = omit, socks5: proxy_connect_params.Socks5 | Omit = omit, user: str | Omit = omit, - wireguard: str | Omit = omit, 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. @@ -65,12 +64,10 @@ def connect( Connect proxy Args: - name: Proxy name (used for wireguard tunnel name) + name: Proxy name socks5: SOCKS5 proxy configuration (required for socks5). - wireguard: WireGuard tunnel configuration file content (required for wireguard). - extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -99,7 +96,6 @@ def connect( "smart_ip": smart_ip, "socks5": socks5, "user": user, - "wireguard": wireguard, }, proxy_connect_params.ProxyConnectParams, ), @@ -222,7 +218,6 @@ async def connect( smart_ip: bool | Omit = omit, socks5: proxy_connect_params.Socks5 | Omit = omit, user: str | Omit = omit, - wireguard: str | Omit = omit, 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. @@ -235,12 +230,10 @@ async def connect( Connect proxy Args: - name: Proxy name (used for wireguard tunnel name) + name: Proxy name socks5: SOCKS5 proxy configuration (required for socks5). - wireguard: WireGuard tunnel configuration file content (required for wireguard). - extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -269,7 +262,6 @@ async def connect( "smart_ip": smart_ip, "socks5": socks5, "user": user, - "wireguard": wireguard, }, proxy_connect_params.ProxyConnectParams, ), diff --git a/src/mobilerun_sdk/types/device_create_params.py b/src/mobilerun_sdk/types/device_create_params.py index 751f16e..8c7fed5 100644 --- a/src/mobilerun_sdk/types/device_create_params.py +++ b/src/mobilerun_sdk/types/device_create_params.py @@ -10,10 +10,16 @@ from .shared_params.device_carrier import DeviceCarrier from .shared_params.device_identifiers import DeviceIdentifiers -__all__ = ["DeviceCreateParams", "Proxy", "ProxySocks5"] +__all__ = ["DeviceCreateParams", "Location", "Proxy", "ProxySocks5"] class DeviceCreateParams(TypedDict, total=False): + query_country: Annotated[str, PropertyInfo(alias="country")] + """ISO 3166-1 alpha-2 country code. + + If omitted the system picks the country with the most availability. + """ + device_type: Annotated[ Literal[ "dedicated_physical_device", "dedicated_premium_device", "dedicated_emulated_device", "dedicated_ios_device" @@ -21,18 +27,34 @@ class DeviceCreateParams(TypedDict, total=False): PropertyInfo(alias="deviceType"), ] + android_version: Annotated[int, PropertyInfo(alias="androidVersion")] + apps: Optional[SequenceNotStr[str]] carrier: DeviceCarrier + body_country: Annotated[str, PropertyInfo(alias="country")] + files: Optional[SequenceNotStr[str]] identifiers: DeviceIdentifiers + locale: str + + location: Location + name: str proxy: Proxy + timezone: str + + +class Location(TypedDict, total=False): + latitude: Required[float] + + longitude: Required[float] + class ProxySocks5(TypedDict, total=False): host: Required[str] @@ -50,5 +72,3 @@ class Proxy(TypedDict, total=False): smart_ip: Annotated[bool, PropertyInfo(alias="smartIp")] socks5: ProxySocks5 - - wireguard: str diff --git a/src/mobilerun_sdk/types/devices/keyboard_write_params.py b/src/mobilerun_sdk/types/devices/keyboard_write_params.py index f0fa72f..c1340d7 100644 --- a/src/mobilerun_sdk/types/devices/keyboard_write_params.py +++ b/src/mobilerun_sdk/types/devices/keyboard_write_params.py @@ -14,6 +14,9 @@ class KeyboardWriteParams(TypedDict, total=False): clear: bool + error_rate: Annotated[float, PropertyInfo(alias="errorRate")] + """Per-character mistake rate for humantouch typing. -1 uses server default.""" + stealth: bool wpm: int diff --git a/src/mobilerun_sdk/types/devices/proxy_connect_params.py b/src/mobilerun_sdk/types/devices/proxy_connect_params.py index d177e00..5afc504 100644 --- a/src/mobilerun_sdk/types/devices/proxy_connect_params.py +++ b/src/mobilerun_sdk/types/devices/proxy_connect_params.py @@ -13,7 +13,7 @@ class ProxyConnectParams(TypedDict, total=False): host: str name: str - """Proxy name (used for wireguard tunnel name)""" + """Proxy name""" password: str @@ -26,9 +26,6 @@ class ProxyConnectParams(TypedDict, total=False): user: str - wireguard: str - """WireGuard tunnel configuration file content (required for wireguard).""" - x_device_display_id: Annotated[int, PropertyInfo(alias="X-Device-Display-ID")] diff --git a/src/mobilerun_sdk/types/devices/proxy_status_response.py b/src/mobilerun_sdk/types/devices/proxy_status_response.py index b457c9d..668f612 100644 --- a/src/mobilerun_sdk/types/devices/proxy_status_response.py +++ b/src/mobilerun_sdk/types/devices/proxy_status_response.py @@ -16,7 +16,7 @@ class ProxyStatusResponse(BaseModel): """Active proxy name""" protocol: Optional[str] = None - """Active proxy protocol (socks5 or wireguard).""" + """Active proxy protocol (socks5).""" schema_: Optional[str] = FieldInfo(alias="$schema", default=None) """A URL to the JSON Schema for this object.""" diff --git a/src/mobilerun_sdk/types/shared/device_spec.py b/src/mobilerun_sdk/types/shared/device_spec.py index 33e54cc..27ce056 100644 --- a/src/mobilerun_sdk/types/shared/device_spec.py +++ b/src/mobilerun_sdk/types/shared/device_spec.py @@ -8,7 +8,16 @@ from .device_carrier import DeviceCarrier from .device_identifiers import DeviceIdentifiers -__all__ = ["DeviceSpec", "Proxy", "ProxySocks5"] +__all__ = ["DeviceSpec", "Location", "Proxy", "ProxySocks5"] + + +class Location(BaseModel): + latitude: float + + longitude: float + + schema_: Optional[str] = FieldInfo(alias="$schema", default=None) + """A URL to the JSON Schema for this object.""" class ProxySocks5(BaseModel): @@ -28,21 +37,29 @@ class Proxy(BaseModel): socks5: Optional[ProxySocks5] = None - wireguard: Optional[str] = None - class DeviceSpec(BaseModel): schema_: Optional[str] = FieldInfo(alias="$schema", default=None) """A URL to the JSON Schema for this object.""" + android_version: Optional[int] = FieldInfo(alias="androidVersion", default=None) + apps: Optional[List[str]] = None carrier: Optional[DeviceCarrier] = None + country: Optional[str] = None + files: Optional[List[str]] = None identifiers: Optional[DeviceIdentifiers] = None + locale: Optional[str] = None + + location: Optional[Location] = None + name: Optional[str] = None proxy: Optional[Proxy] = None + + timezone: Optional[str] = None diff --git a/src/mobilerun_sdk/types/shared_params/device_spec.py b/src/mobilerun_sdk/types/shared_params/device_spec.py index 60bd226..01e5de1 100644 --- a/src/mobilerun_sdk/types/shared_params/device_spec.py +++ b/src/mobilerun_sdk/types/shared_params/device_spec.py @@ -10,7 +10,13 @@ from .device_carrier import DeviceCarrier from .device_identifiers import DeviceIdentifiers -__all__ = ["DeviceSpec", "Proxy", "ProxySocks5"] +__all__ = ["DeviceSpec", "Location", "Proxy", "ProxySocks5"] + + +class Location(TypedDict, total=False): + latitude: Required[float] + + longitude: Required[float] class ProxySocks5(TypedDict, total=False): @@ -30,18 +36,26 @@ class Proxy(TypedDict, total=False): socks5: ProxySocks5 - wireguard: str - class DeviceSpec(TypedDict, total=False): + android_version: Annotated[int, PropertyInfo(alias="androidVersion")] + apps: Optional[SequenceNotStr[str]] carrier: DeviceCarrier + country: str + files: Optional[SequenceNotStr[str]] identifiers: DeviceIdentifiers + locale: str + + location: Location + name: str proxy: Proxy + + timezone: str diff --git a/tests/api_resources/devices/test_keyboard.py b/tests/api_resources/devices/test_keyboard.py index ba20e76..766a63b 100644 --- a/tests/api_resources/devices/test_keyboard.py +++ b/tests/api_resources/devices/test_keyboard.py @@ -138,6 +138,7 @@ def test_method_write_with_all_params(self, client: Mobilerun) -> None: device_id="deviceId", text="text", clear=True, + error_rate=0, stealth=True, wpm=0, x_device_display_id=0, @@ -310,6 +311,7 @@ async def test_method_write_with_all_params(self, async_client: AsyncMobilerun) device_id="deviceId", text="text", clear=True, + error_rate=0, stealth=True, wpm=0, x_device_display_id=0, diff --git a/tests/api_resources/devices/test_proxy.py b/tests/api_resources/devices/test_proxy.py index eb9d960..914b3e3 100644 --- a/tests/api_resources/devices/test_proxy.py +++ b/tests/api_resources/devices/test_proxy.py @@ -42,7 +42,6 @@ def test_method_connect_with_all_params(self, client: Mobilerun) -> None: "user": "user", }, user="user", - wireguard="wireguard", x_device_display_id=0, ) assert proxy is None @@ -214,7 +213,6 @@ async def test_method_connect_with_all_params(self, async_client: AsyncMobilerun "user": "user", }, user="user", - wireguard="wireguard", x_device_display_id=0, ) assert proxy is None diff --git a/tests/api_resources/test_devices.py b/tests/api_resources/test_devices.py index 975ae92..f78a12c 100644 --- a/tests/api_resources/test_devices.py +++ b/tests/api_resources/test_devices.py @@ -32,7 +32,9 @@ def test_method_create(self, client: Mobilerun) -> None: @parametrize def test_method_create_with_all_params(self, client: Mobilerun) -> None: device = client.devices.create( + query_country="country", device_type="dedicated_physical_device", + android_version=0, apps=["string"], carrier={ "gsm_operator_alpha": "GsmOperatorAlpha", @@ -42,6 +44,7 @@ def test_method_create_with_all_params(self, client: Mobilerun) -> None: "gsm_sim_operator_numeric": 0, "persist_sys_timezone": "PersistSysTimezone", }, + body_country="country", files=["string"], identifiers={ "bootloader_serial_number": "BootloaderSerialNumber", @@ -60,6 +63,11 @@ def test_method_create_with_all_params(self, client: Mobilerun) -> None: "identifier_wifi_mac": "IdentifierWifiMAC", "serial_number": "SerialNumber", }, + locale="locale", + location={ + "latitude": 0, + "longitude": 0, + }, name="name", proxy={ "name": "name", @@ -70,8 +78,8 @@ def test_method_create_with_all_params(self, client: Mobilerun) -> None: "port": 0, "user": "user", }, - "wireguard": "wireguard", }, + timezone="timezone", ) assert_matches_type(Device, device, path=["response"]) @@ -367,7 +375,9 @@ async def test_method_create(self, async_client: AsyncMobilerun) -> None: @parametrize async def test_method_create_with_all_params(self, async_client: AsyncMobilerun) -> None: device = await async_client.devices.create( + query_country="country", device_type="dedicated_physical_device", + android_version=0, apps=["string"], carrier={ "gsm_operator_alpha": "GsmOperatorAlpha", @@ -377,6 +387,7 @@ async def test_method_create_with_all_params(self, async_client: AsyncMobilerun) "gsm_sim_operator_numeric": 0, "persist_sys_timezone": "PersistSysTimezone", }, + body_country="country", files=["string"], identifiers={ "bootloader_serial_number": "BootloaderSerialNumber", @@ -395,6 +406,11 @@ async def test_method_create_with_all_params(self, async_client: AsyncMobilerun) "identifier_wifi_mac": "IdentifierWifiMAC", "serial_number": "SerialNumber", }, + locale="locale", + location={ + "latitude": 0, + "longitude": 0, + }, name="name", proxy={ "name": "name", @@ -405,8 +421,8 @@ async def test_method_create_with_all_params(self, async_client: AsyncMobilerun) "port": 0, "user": "user", }, - "wireguard": "wireguard", }, + timezone="timezone", ) assert_matches_type(Device, device, path=["response"]) diff --git a/tests/api_resources/test_profiles.py b/tests/api_resources/test_profiles.py index 6c557de..404e8d8 100644 --- a/tests/api_resources/test_profiles.py +++ b/tests/api_resources/test_profiles.py @@ -36,6 +36,7 @@ def test_method_create_with_all_params(self, client: Mobilerun) -> None: profile = client.profiles.create( name="x", spec={ + "android_version": 0, "apps": ["string"], "carrier": { "gsm_operator_alpha": "GsmOperatorAlpha", @@ -45,6 +46,7 @@ def test_method_create_with_all_params(self, client: Mobilerun) -> None: "gsm_sim_operator_numeric": 0, "persist_sys_timezone": "PersistSysTimezone", }, + "country": "country", "files": ["string"], "identifiers": { "bootloader_serial_number": "BootloaderSerialNumber", @@ -63,6 +65,11 @@ def test_method_create_with_all_params(self, client: Mobilerun) -> None: "identifier_wifi_mac": "IdentifierWifiMAC", "serial_number": "SerialNumber", }, + "locale": "locale", + "location": { + "latitude": 0, + "longitude": 0, + }, "name": "name", "proxy": { "name": "name", @@ -73,8 +80,8 @@ def test_method_create_with_all_params(self, client: Mobilerun) -> None: "port": 0, "user": "user", }, - "wireguard": "wireguard", }, + "timezone": "timezone", }, ) assert_matches_type(Profile, profile, path=["response"]) @@ -166,6 +173,7 @@ def test_method_update_with_all_params(self, client: Mobilerun) -> None: profile_id="profileId", name="x", spec={ + "android_version": 0, "apps": ["string"], "carrier": { "gsm_operator_alpha": "GsmOperatorAlpha", @@ -175,6 +183,7 @@ def test_method_update_with_all_params(self, client: Mobilerun) -> None: "gsm_sim_operator_numeric": 0, "persist_sys_timezone": "PersistSysTimezone", }, + "country": "country", "files": ["string"], "identifiers": { "bootloader_serial_number": "BootloaderSerialNumber", @@ -193,6 +202,11 @@ def test_method_update_with_all_params(self, client: Mobilerun) -> None: "identifier_wifi_mac": "IdentifierWifiMAC", "serial_number": "SerialNumber", }, + "locale": "locale", + "location": { + "latitude": 0, + "longitude": 0, + }, "name": "name", "proxy": { "name": "name", @@ -203,8 +217,8 @@ def test_method_update_with_all_params(self, client: Mobilerun) -> None: "port": 0, "user": "user", }, - "wireguard": "wireguard", }, + "timezone": "timezone", }, ) assert_matches_type(Profile, profile, path=["response"]) @@ -352,6 +366,7 @@ async def test_method_create_with_all_params(self, async_client: AsyncMobilerun) profile = await async_client.profiles.create( name="x", spec={ + "android_version": 0, "apps": ["string"], "carrier": { "gsm_operator_alpha": "GsmOperatorAlpha", @@ -361,6 +376,7 @@ async def test_method_create_with_all_params(self, async_client: AsyncMobilerun) "gsm_sim_operator_numeric": 0, "persist_sys_timezone": "PersistSysTimezone", }, + "country": "country", "files": ["string"], "identifiers": { "bootloader_serial_number": "BootloaderSerialNumber", @@ -379,6 +395,11 @@ async def test_method_create_with_all_params(self, async_client: AsyncMobilerun) "identifier_wifi_mac": "IdentifierWifiMAC", "serial_number": "SerialNumber", }, + "locale": "locale", + "location": { + "latitude": 0, + "longitude": 0, + }, "name": "name", "proxy": { "name": "name", @@ -389,8 +410,8 @@ async def test_method_create_with_all_params(self, async_client: AsyncMobilerun) "port": 0, "user": "user", }, - "wireguard": "wireguard", }, + "timezone": "timezone", }, ) assert_matches_type(Profile, profile, path=["response"]) @@ -482,6 +503,7 @@ async def test_method_update_with_all_params(self, async_client: AsyncMobilerun) profile_id="profileId", name="x", spec={ + "android_version": 0, "apps": ["string"], "carrier": { "gsm_operator_alpha": "GsmOperatorAlpha", @@ -491,6 +513,7 @@ async def test_method_update_with_all_params(self, async_client: AsyncMobilerun) "gsm_sim_operator_numeric": 0, "persist_sys_timezone": "PersistSysTimezone", }, + "country": "country", "files": ["string"], "identifiers": { "bootloader_serial_number": "BootloaderSerialNumber", @@ -509,6 +532,11 @@ async def test_method_update_with_all_params(self, async_client: AsyncMobilerun) "identifier_wifi_mac": "IdentifierWifiMAC", "serial_number": "SerialNumber", }, + "locale": "locale", + "location": { + "latitude": 0, + "longitude": 0, + }, "name": "name", "proxy": { "name": "name", @@ -519,8 +547,8 @@ async def test_method_update_with_all_params(self, async_client: AsyncMobilerun) "port": 0, "user": "user", }, - "wireguard": "wireguard", }, + "timezone": "timezone", }, ) assert_matches_type(Profile, profile, path=["response"]) diff --git a/tests/test_deepcopy.py b/tests/test_deepcopy.py deleted file mode 100644 index db8149c..0000000 --- a/tests/test_deepcopy.py +++ /dev/null @@ -1,58 +0,0 @@ -from mobilerun_sdk._utils import deepcopy_minimal - - -def assert_different_identities(obj1: object, obj2: object) -> None: - assert obj1 == obj2 - assert id(obj1) != id(obj2) - - -def test_simple_dict() -> None: - obj1 = {"foo": "bar"} - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - - -def test_nested_dict() -> None: - obj1 = {"foo": {"bar": True}} - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - assert_different_identities(obj1["foo"], obj2["foo"]) - - -def test_complex_nested_dict() -> None: - obj1 = {"foo": {"bar": [{"hello": "world"}]}} - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - assert_different_identities(obj1["foo"], obj2["foo"]) - assert_different_identities(obj1["foo"]["bar"], obj2["foo"]["bar"]) - assert_different_identities(obj1["foo"]["bar"][0], obj2["foo"]["bar"][0]) - - -def test_simple_list() -> None: - obj1 = ["a", "b", "c"] - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - - -def test_nested_list() -> None: - obj1 = ["a", [1, 2, 3]] - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - assert_different_identities(obj1[1], obj2[1]) - - -class MyObject: ... - - -def test_ignores_other_types() -> None: - # custom classes - my_obj = MyObject() - obj1 = {"foo": my_obj} - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - assert obj1["foo"] is my_obj - - # tuples - obj3 = ("a", "b") - obj4 = deepcopy_minimal(obj3) - assert obj3 is obj4 diff --git a/tests/test_extract_files.py b/tests/test_extract_files.py index 5792e00..bd4e31b 100644 --- a/tests/test_extract_files.py +++ b/tests/test_extract_files.py @@ -4,7 +4,7 @@ import pytest -from mobilerun_sdk._types import FileTypes +from mobilerun_sdk._types import FileTypes, ArrayFormat from mobilerun_sdk._utils import extract_files @@ -37,10 +37,7 @@ def test_multiple_files() -> None: def test_top_level_file_array() -> None: query = {"files": [b"file one", b"file two"], "title": "hello"} - assert extract_files(query, paths=[["files", ""]]) == [ - ("files[]", b"file one"), - ("files[]", b"file two"), - ] + assert extract_files(query, paths=[["files", ""]]) == [("files[]", b"file one"), ("files[]", b"file two")] assert query == {"title": "hello"} @@ -71,3 +68,24 @@ def test_ignores_incorrect_paths( expected: list[tuple[str, FileTypes]], ) -> None: assert extract_files(query, paths=paths) == expected + + +@pytest.mark.parametrize( + "array_format,expected_top_level,expected_nested", + [ + ("brackets", [("files[]", b"a"), ("files[]", b"b")], [("items[][file]", b"a"), ("items[][file]", b"b")]), + ("repeat", [("files", b"a"), ("files", b"b")], [("items[file]", b"a"), ("items[file]", b"b")]), + ("comma", [("files", b"a"), ("files", b"b")], [("items[file]", b"a"), ("items[file]", b"b")]), + ("indices", [("files[0]", b"a"), ("files[1]", b"b")], [("items[0][file]", b"a"), ("items[1][file]", b"b")]), + ], +) +def test_array_format_controls_file_field_names( + array_format: ArrayFormat, + expected_top_level: list[tuple[str, FileTypes]], + expected_nested: list[tuple[str, FileTypes]], +) -> None: + top_level = {"files": [b"a", b"b"]} + assert extract_files(top_level, paths=[["files", ""]], array_format=array_format) == expected_top_level + + nested = {"items": [{"file": b"a"}, {"file": b"b"}]} + assert extract_files(nested, paths=[["items", "", "file"]], array_format=array_format) == expected_nested diff --git a/tests/test_files.py b/tests/test_files.py index 7d4e3e7..97fe070 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -4,7 +4,8 @@ import pytest from dirty_equals import IsDict, IsList, IsBytes, IsTuple -from mobilerun_sdk._files import to_httpx_files, async_to_httpx_files +from mobilerun_sdk._files import to_httpx_files, deepcopy_with_paths, async_to_httpx_files +from mobilerun_sdk._utils import extract_files readme_path = Path(__file__).parent.parent.joinpath("README.md") @@ -49,3 +50,99 @@ def test_string_not_allowed() -> None: "file": "foo", # type: ignore } ) + + +def assert_different_identities(obj1: object, obj2: object) -> None: + assert obj1 == obj2 + assert obj1 is not obj2 + + +class TestDeepcopyWithPaths: + def test_copies_top_level_dict(self) -> None: + original = {"file": b"data", "other": "value"} + result = deepcopy_with_paths(original, [["file"]]) + assert_different_identities(result, original) + + def test_file_value_is_same_reference(self) -> None: + file_bytes = b"contents" + original = {"file": file_bytes} + result = deepcopy_with_paths(original, [["file"]]) + assert_different_identities(result, original) + assert result["file"] is file_bytes + + def test_list_popped_wholesale(self) -> None: + files = [b"f1", b"f2"] + original = {"files": files, "title": "t"} + result = deepcopy_with_paths(original, [["files", ""]]) + assert_different_identities(result, original) + result_files = result["files"] + assert isinstance(result_files, list) + assert_different_identities(result_files, files) + + def test_nested_array_path_copies_list_and_elements(self) -> None: + elem1 = {"file": b"f1", "extra": 1} + elem2 = {"file": b"f2", "extra": 2} + original = {"items": [elem1, elem2]} + result = deepcopy_with_paths(original, [["items", "", "file"]]) + assert_different_identities(result, original) + result_items = result["items"] + assert isinstance(result_items, list) + assert_different_identities(result_items, original["items"]) + assert_different_identities(result_items[0], elem1) + assert_different_identities(result_items[1], elem2) + + def test_empty_paths_returns_same_object(self) -> None: + original = {"foo": "bar"} + result = deepcopy_with_paths(original, []) + assert result is original + + def test_multiple_paths(self) -> None: + f1 = b"file1" + f2 = b"file2" + original = {"a": f1, "b": f2, "c": "unchanged"} + result = deepcopy_with_paths(original, [["a"], ["b"]]) + assert_different_identities(result, original) + assert result["a"] is f1 + assert result["b"] is f2 + assert result["c"] is original["c"] + + def test_extract_files_does_not_mutate_original_top_level(self) -> None: + file_bytes = b"contents" + original = {"file": file_bytes, "other": "value"} + + copied = deepcopy_with_paths(original, [["file"]]) + extracted = extract_files(copied, paths=[["file"]]) + + assert extracted == [("file", file_bytes)] + assert original == {"file": file_bytes, "other": "value"} + assert copied == {"other": "value"} + + def test_extract_files_does_not_mutate_original_nested_array_path(self) -> None: + file1 = b"f1" + file2 = b"f2" + original = { + "items": [ + {"file": file1, "extra": 1}, + {"file": file2, "extra": 2}, + ], + "title": "example", + } + + copied = deepcopy_with_paths(original, [["items", "", "file"]]) + extracted = extract_files(copied, paths=[["items", "", "file"]]) + + assert [entry for _, entry in extracted] == [file1, file2] + assert original == { + "items": [ + {"file": file1, "extra": 1}, + {"file": file2, "extra": 2}, + ], + "title": "example", + } + assert copied == { + "items": [ + {"extra": 1}, + {"extra": 2}, + ], + "title": "example", + }