invilabs 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ include PUBLIC_README.md
2
+ exclude README.md RELEASE_CHECKLIST.md PHOTO_VERIFICATION.md
3
+ prune examples
4
+ prune tools
5
+ prune tests
6
+ global-exclude __pycache__ *.pyc .DS_Store
7
+ recursive-include src/invilabs *.pyi py.typed response_schemas.json
@@ -0,0 +1,85 @@
1
+ Metadata-Version: 2.4
2
+ Name: invilabs
3
+ Version: 0.1.0
4
+ Summary: Blocking Python SDK for Invi Labs phone automation
5
+ Author: Invi Labs
6
+ Project-URL: Homepage, https://invilabs.io
7
+ Project-URL: Documentation, https://sdk.invilabs.io
8
+ Project-URL: Changelog, https://sdk.invilabs.io/downloads/
9
+ Keywords: iphone,automation,sdk,ai-agent,ocr
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Typing :: Typed
13
+ Classifier: Operating System :: OS Independent
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+
17
+ # Invi Labs Python SDK
18
+
19
+ Automate real iPhones through the Invi Labs API using your API key and phone ID.
20
+ Non-jailbroken iPhones require [Invi Dongle](https://invilabs.io/connect).
21
+
22
+ ## Install
23
+
24
+ ```sh
25
+ python -m pip install invilabs
26
+ ```
27
+
28
+ Requires Python 3.10 or newer. No third-party runtime dependencies.
29
+
30
+ ## Connect and run an action
31
+
32
+ Set `INVI_API_KEY` and `INVI_PHONE_ID` in your environment. Keep your API key private.
33
+
34
+ ```python
35
+ import os
36
+ from invilabs import Client
37
+
38
+ client = Client(os.environ["INVI_API_KEY"])
39
+ phone = client.phone(os.environ["INVI_PHONE_ID"])
40
+
41
+ print(phone.details())
42
+ phone.tap(100, 200)
43
+ phone.save_screenshot("screen.png")
44
+ ```
45
+
46
+ Coordinates use full-resolution screenshot pixels. Calls block until completion;
47
+ the SDK waits and retries when the phone reports `device_busy`. Uncertain execution
48
+ is not automatically repeated. Use the operation key to look up its status.
49
+
50
+ ## AI generation
51
+
52
+ ```python
53
+ result = client.generate(
54
+ "Generate an email address using example.com",
55
+ result_type="email",
56
+ )
57
+ print(result["data"]["text"])
58
+ print(client.balance())
59
+ ```
60
+
61
+ Generation supports text, usernames and email address strings, optionally using a
62
+ local PNG/JPEG image. It does not create mailboxes. OCR, AI prompts and generation
63
+ use your shared Account AI balance. No AI-provider key is required.
64
+
65
+ ## Features
66
+
67
+ - Tap, swipe, scroll, type, explicit key presses and opening installed apps.
68
+ - Screenshots as PNG bytes or saved to your computer.
69
+ - Grouped OCR with text and bounding boxes.
70
+ - Single-action AI prompts and structured screen answers.
71
+ - Image/video import through Invi Helper on supported dongle phones.
72
+ - Device details, balance and operation recovery.
73
+
74
+ All network operations use the public HTTPS API with your API key. The package
75
+ contains client code and response contracts; phone control and AI processing run
76
+ on Invi Labs services. API keys remain on your computer and are sent only as an
77
+ authentication header to the configured API. HTTP redirects are not followed.
78
+
79
+ This is an initial release. Device support and feature verification limits are
80
+ listed in the [release notes](https://sdk.invilabs.io/downloads/).
81
+
82
+ [Documentation](https://sdk.invilabs.io/) ·
83
+ [Quickstart](https://sdk.invilabs.io/quickstart/) ·
84
+ [Generation and outputs](https://sdk.invilabs.io/reference/generation/) ·
85
+ [Errors and recovery](https://sdk.invilabs.io/reference/credits-and-recovery/)
@@ -0,0 +1,69 @@
1
+ # Invi Labs Python SDK
2
+
3
+ Automate real iPhones through the Invi Labs API using your API key and phone ID.
4
+ Non-jailbroken iPhones require [Invi Dongle](https://invilabs.io/connect).
5
+
6
+ ## Install
7
+
8
+ ```sh
9
+ python -m pip install invilabs
10
+ ```
11
+
12
+ Requires Python 3.10 or newer. No third-party runtime dependencies.
13
+
14
+ ## Connect and run an action
15
+
16
+ Set `INVI_API_KEY` and `INVI_PHONE_ID` in your environment. Keep your API key private.
17
+
18
+ ```python
19
+ import os
20
+ from invilabs import Client
21
+
22
+ client = Client(os.environ["INVI_API_KEY"])
23
+ phone = client.phone(os.environ["INVI_PHONE_ID"])
24
+
25
+ print(phone.details())
26
+ phone.tap(100, 200)
27
+ phone.save_screenshot("screen.png")
28
+ ```
29
+
30
+ Coordinates use full-resolution screenshot pixels. Calls block until completion;
31
+ the SDK waits and retries when the phone reports `device_busy`. Uncertain execution
32
+ is not automatically repeated. Use the operation key to look up its status.
33
+
34
+ ## AI generation
35
+
36
+ ```python
37
+ result = client.generate(
38
+ "Generate an email address using example.com",
39
+ result_type="email",
40
+ )
41
+ print(result["data"]["text"])
42
+ print(client.balance())
43
+ ```
44
+
45
+ Generation supports text, usernames and email address strings, optionally using a
46
+ local PNG/JPEG image. It does not create mailboxes. OCR, AI prompts and generation
47
+ use your shared Account AI balance. No AI-provider key is required.
48
+
49
+ ## Features
50
+
51
+ - Tap, swipe, scroll, type, explicit key presses and opening installed apps.
52
+ - Screenshots as PNG bytes or saved to your computer.
53
+ - Grouped OCR with text and bounding boxes.
54
+ - Single-action AI prompts and structured screen answers.
55
+ - Image/video import through Invi Helper on supported dongle phones.
56
+ - Device details, balance and operation recovery.
57
+
58
+ All network operations use the public HTTPS API with your API key. The package
59
+ contains client code and response contracts; phone control and AI processing run
60
+ on Invi Labs services. API keys remain on your computer and are sent only as an
61
+ authentication header to the configured API. HTTP redirects are not followed.
62
+
63
+ This is an initial release. Device support and feature verification limits are
64
+ listed in the [release notes](https://sdk.invilabs.io/downloads/).
65
+
66
+ [Documentation](https://sdk.invilabs.io/) ·
67
+ [Quickstart](https://sdk.invilabs.io/quickstart/) ·
68
+ [Generation and outputs](https://sdk.invilabs.io/reference/generation/) ·
69
+ [Errors and recovery](https://sdk.invilabs.io/reference/credits-and-recovery/)
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "invilabs"
7
+ version = "0.1.0"
8
+ description = "Blocking Python SDK for Invi Labs phone automation"
9
+ readme = "PUBLIC_README.md"
10
+ authors = [{name = "Invi Labs"}]
11
+ keywords = ["iphone", "automation", "sdk", "ai-agent", "ocr"]
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "Programming Language :: Python :: 3",
15
+ "Typing :: Typed",
16
+ "Operating System :: OS Independent",
17
+ ]
18
+ requires-python = ">=3.10"
19
+
20
+ [project.urls]
21
+ Homepage = "https://invilabs.io"
22
+ Documentation = "https://sdk.invilabs.io"
23
+ Changelog = "https://sdk.invilabs.io/downloads/"
24
+
25
+ [tool.setuptools.packages.find]
26
+ where = ["src"]
27
+
28
+ [tool.setuptools.package-data]
29
+ invilabs = ["response_schemas.json", "*.pyi", "py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,8 @@
1
+ from .client import Client, Phone
2
+ from .errors import (InviError, DeviceBusyError, DeviceOfflineError,
3
+ InsufficientCreditsError, TargetNotFoundError, InvalidArgumentError,
4
+ ExecutionUncertainError, OperationInProgressError, IdempotencyConflictError,
5
+ IdempotencyExpiredError, AuthenticationError, PermissionDeniedError,
6
+ OperationNotSupportedError, AppNotFoundError, AmbiguousAppError, ScreenshotUnavailableError, ScreenChangedError, OrientationNotSupportedError, BillingPendingError, OCRUnavailableError, ResponseValidationError, MultiStepRequestError, UnsupportedPromptError, PromptUnavailableError, GenerationUnavailableError)
7
+
8
+ __all__ = ['Client', 'Phone', 'InviError', 'DeviceBusyError', 'DeviceOfflineError', 'InsufficientCreditsError', 'TargetNotFoundError', 'InvalidArgumentError', 'ExecutionUncertainError', 'OperationInProgressError', 'IdempotencyConflictError', 'IdempotencyExpiredError', 'AuthenticationError', 'PermissionDeniedError', 'OperationNotSupportedError', 'AppNotFoundError', 'AmbiguousAppError', 'ScreenshotUnavailableError', 'ScreenChangedError', 'OrientationNotSupportedError', 'BillingPendingError', 'OCRUnavailableError', 'ResponseValidationError', 'MultiStepRequestError', 'UnsupportedPromptError', 'PromptUnavailableError', 'GenerationUnavailableError']
@@ -0,0 +1,310 @@
1
+ """Blocking transport. Public device methods are added with their backend milestones."""
2
+ import base64
3
+ import binascii
4
+ import os
5
+ from pathlib import Path
6
+ import struct
7
+ import tempfile
8
+ import json
9
+ import math
10
+ import socket
11
+ import time
12
+ import uuid
13
+ from urllib.error import HTTPError, URLError
14
+ from urllib.parse import quote, urlsplit
15
+ from urllib.request import Request, build_opener, HTTPRedirectHandler
16
+
17
+ from .errors import (ERRORS, InviError, AuthenticationError, PermissionDeniedError,
18
+ DeviceBusyError, ExecutionUncertainError)
19
+
20
+ class _NoRedirect(HTTPRedirectHandler):
21
+ # Never forward an API key to a redirect target, or turn POST into GET.
22
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
23
+ return None
24
+
25
+ class _HTTPTransport:
26
+ def __init__(self):
27
+ self.opener = build_opener(_NoRedirect())
28
+
29
+ def request(self, method, url, headers, body, timeout):
30
+ req = Request(url, data=body, headers=headers, method=method)
31
+ try:
32
+ with self.opener.open(req, timeout=timeout) as response:
33
+ return response.status, response.read()
34
+ except HTTPError as error:
35
+ return error.code, error.read()
36
+
37
+ class Client:
38
+ def __init__(self, api_key, *, base_url="https://api.invilabs.io",
39
+ busy_timeout=60.0, request_timeout=70.0, _transport=None,
40
+ _sleep=time.sleep, _monotonic=time.monotonic, _time=time.time):
41
+ if not isinstance(api_key, str) or not api_key.strip():
42
+ raise ValueError("api_key is required")
43
+ parts = urlsplit(base_url)
44
+ if parts.scheme != "https" and not (parts.scheme == "http" and parts.hostname in ("localhost", "127.0.0.1", "::1")):
45
+ raise ValueError("base_url must use HTTPS (HTTP allowed only for localhost)")
46
+ if not parts.hostname or parts.username or parts.password or parts.query or parts.fragment:
47
+ raise ValueError("invalid base_url")
48
+ if busy_timeout is not None and (not math.isfinite(busy_timeout) or busy_timeout < 0):
49
+ raise ValueError("busy_timeout must be non-negative or None for unlimited")
50
+ if not math.isfinite(request_timeout) or request_timeout <= 0:
51
+ raise ValueError("request_timeout must be positive")
52
+ self._api_key = api_key
53
+ self._base_url = base_url.rstrip("/")
54
+ self._busy_timeout = busy_timeout
55
+ self._request_timeout = request_timeout
56
+ self._transport = _transport or _HTTPTransport()
57
+ self._sleep, self._monotonic, self._time = _sleep, _monotonic, _time
58
+
59
+ def balance(self):
60
+ """Return remaining shared Account AI credits (a snapshot)."""
61
+ return self._request("GET", "/v1/sdk/balance")["balance_remaining"]
62
+
63
+ def generate(self, prompt, *, result_type="text", length=None, image=None):
64
+ """Answer or generate text, optionally using an uploaded PNG/JPEG.
65
+
66
+ No phone required. Optional length is an exact Unicode character count.
67
+ A new call requests a new variation; usernames are not guaranteed unique.
68
+ """
69
+ from .errors import InvalidArgumentError
70
+ if not isinstance(prompt, str) or not prompt.strip() or any(0xD800 <= ord(c) <= 0xDFFF for c in prompt) or len(prompt.encode("utf-8")) > 4096:
71
+ raise InvalidArgumentError("prompt must contain 1–4096 UTF-8 bytes", code="invalid_argument")
72
+ if result_type not in ("text", "username", "email"):
73
+ raise InvalidArgumentError("result_type must be text, username, or email", code="invalid_argument")
74
+ maximum = {"username": 64, "email": 254}.get(result_type, 4000)
75
+ minimum = 5 if result_type == "email" else 1
76
+ if length is not None and (type(length) is not int or not minimum <= length <= maximum):
77
+ raise InvalidArgumentError(f"length must be an integer from {minimum} to {maximum}", code="invalid_argument")
78
+ payload = {"prompt": prompt, "result_type": result_type}
79
+ if length is not None:
80
+ payload["length"] = length
81
+ if image is not None:
82
+ if isinstance(image, bytes):
83
+ data = image
84
+ elif isinstance(image, (str, os.PathLike)):
85
+ if isinstance(image, str) and image.startswith(("https://", "http://", "data:")):
86
+ raise InvalidArgumentError("image must be a local file or bytes, not a URL", code="invalid_argument")
87
+ with Path(image).open("rb") as source:
88
+ data = source.read((5 << 20) + 1)
89
+ else:
90
+ raise InvalidArgumentError("image must be a local file path or bytes", code="invalid_argument")
91
+ if not data or len(data) > 5 << 20 or not (data.startswith(b"\x89PNG\r\n\x1a\n") or data.startswith(b"\xff\xd8\xff")):
92
+ raise InvalidArgumentError("image must be a PNG/JPEG of at most 5 MiB", code="invalid_argument")
93
+ payload["image_base64"] = base64.b64encode(data).decode("ascii")
94
+ return self._request("POST", "/v1/sdk/generate", payload, idempotent=True)
95
+
96
+ def operation(self, key):
97
+ """Look up account-only generation without generating or charging again."""
98
+ return self._request("GET", f"/v1/sdk/operations?key={quote(key, safe='')}")
99
+
100
+ def devices(self):
101
+ """List phones accessible to this API key. Use phone.details() for status."""
102
+ return self._request("GET", "/v1/sdk/devices")["devices"]
103
+
104
+ def phone(self, phone_id):
105
+ return Phone(self, str(uuid.UUID(str(phone_id))))
106
+
107
+ def _request(self, method, path, payload=None, *, idempotent=False, timeout=None):
108
+ body = None if payload is None else json.dumps(payload, sort_keys=True, separators=(",", ":"), allow_nan=False).encode()
109
+ headers = {"Authorization": "Bearer " + self._api_key, "Accept": "application/json", "User-Agent": "invilabs-python/0.1.0"}
110
+ if body is not None:
111
+ headers["Content-Type"] = "application/json"
112
+ created = self._time()
113
+ if idempotent:
114
+ headers["Idempotency-Key"] = f"{int(created)}.{uuid.uuid4()}"
115
+ started = self._monotonic()
116
+ delay = 0.25
117
+ while True:
118
+ if idempotent and self._time() - created >= 86400:
119
+ from .errors import IdempotencyExpiredError
120
+ raise IdempotencyExpiredError("Operation key expired; request was not resent", code="idempotency_expired")
121
+ try:
122
+ status, raw = self._transport.request(method, self._base_url + path, headers.copy(), body, self._request_timeout if timeout is None else timeout)
123
+ except (URLError, OSError, socket.timeout) as exc:
124
+ # A transport failure does not establish whether input executed.
125
+ err = ExecutionUncertainError("Response unavailable; operation was not repeated", code="execution_uncertain")
126
+ err.idempotency_key = headers.get("Idempotency-Key")
127
+ raise err from exc
128
+ try:
129
+ result = json.loads(raw)
130
+ except (ValueError, TypeError) as exc:
131
+ err = ExecutionUncertainError("Invalid response; operation was not repeated", code="execution_uncertain")
132
+ err.idempotency_key = headers.get("Idempotency-Key")
133
+ raise err from exc
134
+ from .contracts import validate_response
135
+ validate_response(method, path, payload, result, status, headers.get("Idempotency-Key"))
136
+ if 200 <= status < 300:
137
+ return result
138
+ if not isinstance(result, dict):
139
+ raise InviError("Invalid error response", status=status)
140
+ code = result.get("error", "http_error")
141
+ if code == "device_busy":
142
+ elapsed = self._monotonic() - started
143
+ remaining = None if self._busy_timeout is None else self._busy_timeout - elapsed
144
+ if remaining is None or remaining > 0:
145
+ self._sleep(delay if remaining is None else min(delay, remaining))
146
+ if remaining is not None and self._monotonic() - started >= self._busy_timeout:
147
+ raise DeviceBusyError("Phone remained busy until wait timeout", code=code, status=status)
148
+ delay = min(delay * 2, 2.0)
149
+ continue
150
+ cls = ERRORS.get(code, AuthenticationError if status == 401 else PermissionDeniedError if status == 403 else InviError)
151
+ details = dict(result.get("details") or {})
152
+ for field in ("credits_charged", "balance_remaining"):
153
+ if field in result:
154
+ details[field] = result[field]
155
+ error = cls(result.get("message", code), code=code, status=status, details=details)
156
+ error.idempotency_key = headers.get("Idempotency-Key")
157
+ raise error
158
+
159
+ class Phone:
160
+ def __init__(self, client, phone_id):
161
+ self._client = client
162
+ self.id = phone_id
163
+
164
+ def upload_media(self, path):
165
+ """Save PNG/JPEG or MP4/MOV through Helper, then delete the Library copy.
166
+
167
+ Images: 10 MiB / 40 MP. H.264/HEVC video: 40 MiB. Dongle phones only.
168
+ Requires device.control and device.upload. A cleanup_failed result means
169
+ the phone saved the media; do not import it again. No AI credit debit.
170
+ """
171
+ from .errors import InvalidArgumentError
172
+ source = Path(path)
173
+ ext = source.suffix.lower()
174
+ if ext not in (".png", ".jpg", ".jpeg", ".mp4", ".mov"):
175
+ raise InvalidArgumentError("media must be PNG/JPEG or MP4/MOV", code="invalid_argument")
176
+ limit = (40 if ext in (".mp4", ".mov") else 10) * 1024 * 1024
177
+ with source.open("rb") as handle:
178
+ data = handle.read(limit + 1)
179
+ if not data or len(data) > limit:
180
+ raise InvalidArgumentError("media exceeds size limit (images 10 MiB; videos 40 MiB)", code="invalid_argument")
181
+ return self._client._request(
182
+ "POST", f"/v1/sdk/devices/{self.id}/operations/upload_media",
183
+ {"filename": source.name, "media_base64": base64.b64encode(data).decode("ascii")},
184
+ idempotent=True, timeout=max(170.0, self._client._request_timeout))
185
+
186
+ def prompt(self, prompt, *, result_type="action"):
187
+ """One action, or read-only target/answer using fixed SDK-owned schemas.
188
+
189
+ Be specific about visible text and screen location. Target and answer
190
+ never send input. No custom schema, navigation, or action repetition.
191
+ """
192
+ from .errors import InvalidArgumentError
193
+ if not isinstance(prompt, str) or not prompt.strip() or any(0xD800 <= ord(c) <= 0xDFFF for c in prompt) or len(prompt.encode("utf-8")) > 4096:
194
+ raise InvalidArgumentError("prompt must contain 1–4096 UTF-8 bytes", code="invalid_argument")
195
+ if result_type not in ("action", "target", "answer"):
196
+ raise InvalidArgumentError("result_type must be action, target, or answer", code="invalid_argument")
197
+ return self._operation("prompt", {"prompt": prompt, "result_type": result_type})
198
+
199
+ def ocr(self):
200
+ """Full-screen grouped text and PNG-pixel boxes, plus charge and balance."""
201
+ return self._operation("ocr", {})
202
+
203
+ def details(self):
204
+ """Read online/busy state and screenshot-pixel dimensions (screen may be None)."""
205
+ return self._client._request("GET", f"/v1/sdk/devices/{self.id}")
206
+
207
+ def screenshot(self):
208
+ """Return a fresh full-screen PNG as bytes, in the same pixels as tap/swipe.
209
+
210
+ Busy waits reuse one request key. A replay of an existing operation
211
+ returns its original image; a new screenshot() call captures again.
212
+ """
213
+ from .errors import ScreenshotUnavailableError
214
+ result = self._operation("screenshot", {})
215
+ try:
216
+ data = base64.b64decode(result["image_base64"], validate=True)
217
+ width, height = struct.unpack(">II", data[16:24])
218
+ if (result["format"] != "png" or data[:8] != b"\x89PNG\r\n\x1a\n"
219
+ or data[8:16] != b"\x00\x00\x00\rIHDR"
220
+ or data[-12:] != b"\x00\x00\x00\x00IEND\xaeB`\x82" or width < 2 or height < 2
221
+ or width != result["width"] or height != result["height"]):
222
+ raise ValueError("invalid PNG dimensions")
223
+ except (KeyError, TypeError, ValueError, binascii.Error, struct.error) as exc:
224
+ raise ScreenshotUnavailableError("Invalid screenshot response", code="screenshot_unavailable") from exc
225
+ return data
226
+
227
+ def save_screenshot(self, path):
228
+ """Capture and atomically save PNG to path; return Path. Local errors raise OSError.
229
+
230
+ Existing files are replaced only after the complete PNG has been written.
231
+ Parent directories must exist. No second capture on a local write error.
232
+ """
233
+ target = Path(path)
234
+ temporary = None
235
+ try:
236
+ with tempfile.NamedTemporaryFile(dir=target.parent, prefix=".invilabs-", suffix=".png", delete=False) as f:
237
+ temporary = Path(f.name)
238
+ f.write(self.screenshot())
239
+ f.flush()
240
+ os.fsync(f.fileno())
241
+ os.replace(temporary, target)
242
+ return target
243
+ finally:
244
+ if temporary is not None:
245
+ temporary.unlink(missing_ok=True)
246
+
247
+ def tap(self, x, y):
248
+ """Tap once in native screen pixels; wait for execution acknowledgment."""
249
+ if isinstance(x, bool) or isinstance(y, bool) or not isinstance(x, int) or not isinstance(y, int) or x < 0 or y < 0:
250
+ from .errors import InvalidArgumentError
251
+ raise InvalidArgumentError("x and y must be non-negative integers", code="invalid_argument")
252
+ return self._operation("tap", {"x": x, "y": y})
253
+
254
+ @staticmethod
255
+ def _integer(value, name, minimum=0, maximum=None):
256
+ if isinstance(value, bool) or not isinstance(value, int) or value < minimum or (maximum is not None and value > maximum):
257
+ from .errors import InvalidArgumentError
258
+ raise InvalidArgumentError(f"Invalid {name}", code="invalid_argument")
259
+
260
+ def swipe(self, x1, y1, x2, y2, *, duration_ms=500):
261
+ """One swipe between native-pixel endpoints; duration 1–5000 ms."""
262
+ for name, value in (("x1",x1),("y1",y1),("x2",x2),("y2",y2)):
263
+ self._integer(value, name)
264
+ self._integer(duration_ms, "duration_ms", 1, 5000)
265
+ return self._operation("swipe", dict(x1=x1,y1=y1,x2=x2,y2=y2,duration_ms=duration_ms))
266
+
267
+ def scroll(self, direction, *, distance=None, duration_ms=500):
268
+ """One gesture: down moves the finger upward; up moves it downward.
269
+
270
+ Distance is native pixels (default 60% of screen height), not the
271
+ resulting distance the content moves.
272
+ """
273
+ from .errors import InvalidArgumentError
274
+ if direction not in ("up", "down"):
275
+ raise InvalidArgumentError("direction must be up or down", code="invalid_argument")
276
+ self._integer(duration_ms, "duration_ms", 1, 5000)
277
+ args = dict(direction=direction,duration_ms=duration_ms)
278
+ if distance is not None:
279
+ self._integer(distance,"distance",1)
280
+ args["distance"] = distance
281
+ return self._operation("scroll",args)
282
+
283
+ def type(self, text):
284
+ """Insert text without clearing or submitting. Use press('enter') explicitly."""
285
+ from .errors import InvalidArgumentError
286
+ import unicodedata
287
+ if not isinstance(text,str) or not text or any(unicodedata.category(c) in ("Cc", "Cs") for c in text) or len(text.encode("utf-8")) > 1024:
288
+ raise InvalidArgumentError("text must be 1–1024 UTF-8 bytes without control characters",code="invalid_argument")
289
+ return self._operation("type",{"text":text})
290
+
291
+ def press(self, key):
292
+ """Press enter, backspace, home, or app_switcher."""
293
+ from .errors import InvalidArgumentError
294
+ if key not in ("enter","backspace","home","app_switcher"):
295
+ raise InvalidArgumentError("unsupported key",code="invalid_argument")
296
+ return self._operation("press",{"key":key})
297
+
298
+ def open_app(self, app):
299
+ """Open an installed app by name or bundle ID; never installs an app."""
300
+ from .errors import InvalidArgumentError
301
+ if not isinstance(app,str) or not app.strip() or any(ord(c)<32 or 127<=ord(c)<=159 or 0xD800<=ord(c)<=0xDFFF for c in app) or len(app.encode("utf-8")) > 255:
302
+ raise InvalidArgumentError("app name or bundle ID required",code="invalid_argument")
303
+ return self._operation("open_app",{"app":app.strip()})
304
+
305
+ def operation(self, key):
306
+ """Read a prior operation's status without repeating its device action."""
307
+ return self._client._request("GET", f"/v1/sdk/devices/{self.id}/operations?key={quote(key, safe='')}")
308
+
309
+ def _operation(self, name, payload):
310
+ return self._client._request("POST", f"/v1/sdk/devices/{self.id}/operations/{quote(name, safe='')}", payload, idempotent=True)
@@ -0,0 +1,36 @@
1
+ from os import PathLike
2
+ from pathlib import Path
3
+ from typing import Literal, overload
4
+ from .types import (DeviceDetails, DevicesResultDevicesItem, TapResult, SwipeResult,
5
+ ScrollResult, TypeResult, PressResult, OpenAppResult, OcrResult, PromptActionResult,
6
+ PromptTargetResult, PromptAnswerResult, OperationResult, UploadMediaResult, GenerateResult)
7
+
8
+ class Client:
9
+ def __init__(self, api_key: str, *, base_url: str = ..., busy_timeout: float | None = ..., request_timeout: float = ...) -> None: ...
10
+ def balance(self) -> float: ...
11
+ def generate(self, prompt: str, *, result_type: Literal['text', 'username', 'email'] = ..., length: int | None = ..., image: str | PathLike[str] | bytes | None = ...) -> GenerateResult: ...
12
+ def operation(self, key: str) -> OperationResult: ...
13
+ def devices(self) -> list[DevicesResultDevicesItem]: ...
14
+ def phone(self, phone_id: str) -> Phone: ...
15
+
16
+ class Phone:
17
+ id: str
18
+ def __init__(self, client: Client, phone_id: str) -> None: ...
19
+ def details(self) -> DeviceDetails: ...
20
+ def tap(self, x: int, y: int) -> TapResult: ...
21
+ def swipe(self, x1: int, y1: int, x2: int, y2: int, *, duration_ms: int = ...) -> SwipeResult: ...
22
+ def scroll(self, direction: Literal['up', 'down'], *, distance: int | None = ..., duration_ms: int = ...) -> ScrollResult: ...
23
+ def type(self, text: str) -> TypeResult: ...
24
+ def press(self, key: Literal['enter', 'backspace', 'home', 'app_switcher']) -> PressResult: ...
25
+ def open_app(self, app: str) -> OpenAppResult: ...
26
+ def screenshot(self) -> bytes: ...
27
+ def save_screenshot(self, path: str | PathLike[str]) -> Path: ...
28
+ def ocr(self) -> OcrResult: ...
29
+ @overload
30
+ def prompt(self, prompt: str, *, result_type: Literal['action'] = ...) -> PromptActionResult: ...
31
+ @overload
32
+ def prompt(self, prompt: str, *, result_type: Literal['target']) -> PromptTargetResult: ...
33
+ @overload
34
+ def prompt(self, prompt: str, *, result_type: Literal['answer']) -> PromptAnswerResult: ...
35
+ def operation(self, key: str) -> OperationResult: ...
36
+ def upload_media(self, path: str | PathLike[str]) -> UploadMediaResult: ...
@@ -0,0 +1,71 @@
1
+ """SDK-owned v1 wire contracts. Invalid responses never cause a resend."""
2
+ import json
3
+ import math
4
+ import re
5
+ from importlib.resources import files
6
+ from .errors import ResponseValidationError
7
+
8
+ EMAIL = re.compile('^[A-Za-z0-9_%+-]+(?:\\.[A-Za-z0-9_%+-]+)*@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$')
9
+
10
+ def _valid_email(value):
11
+ return len(value) <= 254 and value.find('@') <= 64 and EMAIL.fullmatch(value) is not None
12
+
13
+ SCHEMAS = json.loads(files('invilabs').joinpath('response_schemas.json').read_text())['schemas']
14
+
15
+ def _matches(value, schema):
16
+ if 'anyOf' in schema:
17
+ return any(_matches(value, candidate) for candidate in schema['anyOf'])
18
+ kind = schema.get('type')
19
+ if kind == 'object':
20
+ if not isinstance(value, dict): return False
21
+ props = schema.get('properties', {})
22
+ if not all(key in value for key in schema.get('required', [])): return False
23
+ if schema.get('additionalProperties') is False and set(value) - set(props): return False
24
+ return all(_matches(value[key], spec) for key, spec in props.items() if key in value)
25
+ if kind == 'array':
26
+ return isinstance(value, list) and all(_matches(item, schema['items']) for item in value)
27
+ if kind == 'string': valid = isinstance(value, str)
28
+ elif kind == 'boolean': valid = isinstance(value, bool)
29
+ elif kind == 'null': valid = value is None
30
+ elif kind == 'integer': valid = type(value) is int
31
+ elif kind == 'number': valid = type(value) in (int, float) and math.isfinite(value)
32
+ else: return False
33
+ if not valid: return False
34
+ if 'minimum' in schema and value < schema['minimum']: return False
35
+ if 'maximum' in schema and value > schema['maximum']: return False
36
+ if 'minLength' in schema and len(value) < schema['minLength']: return False
37
+ if 'maxLength' in schema and len(value) > schema['maxLength']: return False
38
+ return 'enum' not in schema or value in schema['enum']
39
+
40
+ def validate_response(method, path, payload, result, status, key):
41
+ if status >= 400:
42
+ name = 'error'
43
+ elif '/operations?' in path: name = 'operation'
44
+ elif '/operations/' in path:
45
+ name = path.rsplit('/', 1)[-1]
46
+ if name == 'prompt': name += '_' + (payload or {}).get('result_type', 'action')
47
+ elif path.endswith('/generate'): name = 'generate'
48
+ elif path.endswith('/balance'): name = 'balance'
49
+ elif path.endswith('/devices'): name = 'devices'
50
+ else: name = 'details'
51
+ valid = name in SCHEMAS and _matches(result, SCHEMAS[name])
52
+ generated = result.get('result') if valid and name == 'operation' else result
53
+ if valid and isinstance(generated, dict) and generated.get('operation') == 'generate':
54
+ data, mode = generated['data'], generated['result_type']
55
+ text = data['text']
56
+ valid = bool(text.strip()) and data['length'] == len(text) and not any(0xD800 <= ord(c) <= 0xDFFF for c in text)
57
+ if mode == 'username': valid = valid and len(text) <= 64 and re.fullmatch(r'[A-Za-z0-9_]+', text) is not None
58
+ if mode == 'email': valid = valid and _valid_email(text)
59
+ if name == 'generate':
60
+ valid = valid and mode == (payload or {}).get('result_type', 'text')
61
+ expected = (payload or {}).get('length')
62
+ if expected is not None: valid = valid and len(text) == expected
63
+ if not valid:
64
+ details = {}
65
+ if isinstance(result, dict):
66
+ details = {k: result[k] for k in ('credits_charged', 'balance_remaining')
67
+ if k in result and _matches(result[k], {'type':'number','minimum':0})}
68
+ error = ResponseValidationError('Response violates the SDK v1 contract; request was not repeated', code='response_validation_failed', status=status, details=details)
69
+ error.idempotency_key = key
70
+ raise error
71
+ return result