aimlib 0.4.2__py3-none-any.whl

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.
aimlib/__init__.py ADDED
@@ -0,0 +1,917 @@
1
+ """Async customer SDK for aimlib mobile proxies and remote-browser sessions.
2
+
3
+ The SDK reads ``AIMLIB_API_KEY`` and ``AIMLIB_BASE_URL`` by default. Proxy URLs and browser
4
+ connection tokens are credentials; use them without printing or logging them. Remote browsers run on
5
+ the leased phone, so no browser binary is downloaded or launched on the customer's computer.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import importlib
11
+ import os
12
+ import re
13
+ from typing import Optional
14
+ from urllib.parse import urlsplit, urlunsplit
15
+
16
+ import httpx
17
+
18
+ __all__ = [
19
+ "Aimlib", "Device", "Proxy", "BrowserSession", "Ticket",
20
+ "AimlibError", "BrowserPolicyError", "BrowserUnavailableError", "CapacityError",
21
+ "LeaseInactiveError", "DataCapError", "OperationTimeout", "SessionExpiredError", "SessionTimeout", "TabLimitError",
22
+ ]
23
+
24
+
25
+ class AimlibError(Exception):
26
+ pass
27
+
28
+
29
+ class BrowserPolicyError(AimlibError):
30
+ """The requested browser capability is intentionally disabled by managed-browser policy."""
31
+
32
+
33
+ class CapacityError(AimlibError):
34
+ pass
35
+
36
+
37
+ class LeaseInactiveError(AimlibError):
38
+ pass
39
+
40
+
41
+ class DataCapError(AimlibError):
42
+ pass
43
+
44
+
45
+ class SessionExpiredError(AimlibError):
46
+ pass
47
+
48
+
49
+ class SessionTimeout(AimlibError):
50
+ pass
51
+
52
+
53
+ class OperationTimeout(AimlibError):
54
+ pass
55
+
56
+
57
+ class TabLimitError(AimlibError):
58
+ pass
59
+
60
+
61
+ class BrowserUnavailableError(AimlibError):
62
+ """The remote browser cannot currently be created or reached."""
63
+
64
+
65
+ _ERROR_BY_CODE = {
66
+ "capacity_unavailable": CapacityError,
67
+ "lease_inactive": LeaseInactiveError,
68
+ "data_cap_exceeded": DataCapError,
69
+ "session_expired": SessionExpiredError,
70
+ "session_provisioning": SessionTimeout,
71
+ "browser_unavailable": BrowserUnavailableError,
72
+ "device_unavailable": BrowserUnavailableError,
73
+ }
74
+
75
+
76
+ def _ttl_seconds(v) -> int:
77
+ if isinstance(v, (int, float)):
78
+ return int(v)
79
+ m = re.fullmatch(r"\s*(\d+)\s*([smh]?)\s*", str(v))
80
+ if not m:
81
+ raise ValueError(f"bad duration {v!r}")
82
+ return int(m.group(1)) * {"s": 1, "m": 60, "h": 3600}[m.group(2) or "s"]
83
+
84
+
85
+ def _raise_for_typed(r: httpx.Response):
86
+ if r.status_code < 400:
87
+ return
88
+ code, msg = "", ""
89
+ try:
90
+ body = r.json()
91
+ code = body.get("error", "")
92
+ msg = body.get("message", code)
93
+ except Exception: # noqa: BLE001
94
+ msg = r.text[:200]
95
+ raise _ERROR_BY_CODE.get(code, AimlibError)(msg or f"HTTP {r.status_code}")
96
+
97
+
98
+ async def _reject_detectable_browser_binding(*_args, **_kwargs):
99
+ raise BrowserPolicyError(
100
+ "page and context bindings are unavailable in managed-browser sessions"
101
+ )
102
+
103
+
104
+ def _apply_browser_driver_policy(driver_api):
105
+ """Disable page-visible Python bindings before returning the raw driver objects to callers."""
106
+ try:
107
+ driver_types = (driver_api.Page, driver_api.BrowserContext)
108
+ async_playwright = driver_api.async_playwright
109
+ except AttributeError as exc:
110
+ raise AimlibError("browser driver is incompatible with this SDK release") from exc
111
+ for driver_type in driver_types:
112
+ driver_type.expose_function = _reject_detectable_browser_binding
113
+ driver_type.expose_binding = _reject_detectable_browser_binding
114
+ return async_playwright
115
+
116
+
117
+ def _async_playwright():
118
+ """Load the supported browser client and fail closed when it is unavailable."""
119
+ try:
120
+ return _apply_browser_driver_policy(importlib.import_module("patchright.async_api"))
121
+ except ImportError as driver_error:
122
+ raise AimlibError(
123
+ 'browser support is not installed; install the SDK with: pip install "aimlib[browser]"'
124
+ ) from driver_error
125
+
126
+
127
+ def _browser_connect_failure_code(exc: Exception) -> str:
128
+ """Reduce a driver failure to a stable, non-secret diagnostic category.
129
+
130
+ Browser-driver errors can include the full connection endpoint in their call log. The SDK must
131
+ not copy that text into customer logs because it contains the browser-session identifier. A
132
+ small category distinguishes transport, authorization, policy, and browser-startup failures
133
+ while keeping URLs, headers, tokens, and device addresses out of the exception.
134
+ """
135
+ if isinstance(exc, httpx.TimeoutException):
136
+ return "discovery_timeout"
137
+ if isinstance(exc, httpx.TransportError):
138
+ return "discovery_transport_failed"
139
+ if isinstance(exc, BrowserPolicyError):
140
+ return "browser_policy_denied"
141
+ text = str(exc).lower()
142
+ if "managed download guard unavailable" in text:
143
+ return "browser_security_unavailable"
144
+ if "blocked by " in text or (
145
+ "blocked" in text and ("browser" in text or "connection" in text)
146
+ ):
147
+ return "browser_policy_denied"
148
+ if "403" in text and ("websocket" in text or "unexpected server response" in text):
149
+ return "browser_access_denied"
150
+ if "502" in text:
151
+ return "browser_unavailable"
152
+ if "target page, context or browser has been closed" in text or "browser closed" in text:
153
+ return "browser_closed"
154
+ if "websocket" in text:
155
+ return "browser_connection_failed"
156
+ if "timeout" in text or "timed out" in text:
157
+ return "browser_connection_timeout"
158
+ return "browser_connection_failed"
159
+
160
+
161
+ def _validated_ua_brand_rows(value, *, label: str) -> list[dict[str, str]]:
162
+ if not isinstance(value, list) or not 1 <= len(value) <= 8:
163
+ raise BrowserPolicyError(f"managed browser {label} metadata is unavailable")
164
+ rows: list[dict[str, str]] = []
165
+ for row in value:
166
+ if not isinstance(row, dict):
167
+ raise BrowserPolicyError(f"managed browser {label} metadata is invalid")
168
+ brand = row.get("brand")
169
+ version = row.get("version")
170
+ if (
171
+ not isinstance(brand, str)
172
+ or not isinstance(version, str)
173
+ or not 1 <= len(brand) <= 64
174
+ or not 1 <= len(version) <= 64
175
+ ):
176
+ raise BrowserPolicyError(f"managed browser {label} metadata is invalid")
177
+ rows.append({"brand": brand, "version": version})
178
+ return rows
179
+
180
+
181
+ def _with_google_chrome_brand(rows: list[dict[str, str]], *, label: str) -> list[dict[str, str]]:
182
+ if any(row["brand"] == "Google Chrome" for row in rows):
183
+ return rows
184
+ chromium_index = next(
185
+ (index for index, row in enumerate(rows) if row["brand"] == "Chromium"),
186
+ None,
187
+ )
188
+ if chromium_index is None:
189
+ raise BrowserPolicyError(f"managed browser {label} Chromium brand is unavailable")
190
+ branded = list(rows)
191
+ branded.insert(
192
+ chromium_index + 1,
193
+ {"brand": "Google Chrome", "version": rows[chromium_index]["version"]},
194
+ )
195
+ return branded
196
+
197
+
198
+ def _google_chrome_user_agent_override(identity: object) -> Optional[dict]:
199
+ """Build a coherent ChromePublic -> Google Chrome UA-CH override from native phone values."""
200
+ if not isinstance(identity, dict):
201
+ raise BrowserPolicyError("managed browser identity metadata is unavailable")
202
+ user_agent = identity.get("userAgent")
203
+ navigator_platform = identity.get("navigatorPlatform")
204
+ ua_platform = identity.get("platform")
205
+ string_fields = {
206
+ "userAgent": user_agent,
207
+ "navigatorPlatform": navigator_platform,
208
+ "platform": ua_platform,
209
+ "platformVersion": identity.get("platformVersion"),
210
+ "architecture": identity.get("architecture"),
211
+ "model": identity.get("model"),
212
+ "bitness": identity.get("bitness"),
213
+ }
214
+ if any(not isinstance(value, str) or len(value) > 512 for value in string_fields.values()):
215
+ raise BrowserPolicyError("managed browser identity metadata is invalid")
216
+ if not user_agent or "Android" not in user_agent or "Mobile" not in user_agent:
217
+ raise BrowserPolicyError("managed browser did not present a mobile Android user agent")
218
+ if ua_platform != "Android" or identity.get("mobile") is not True:
219
+ raise BrowserPolicyError("managed browser did not present Android client hints")
220
+
221
+ brands = _validated_ua_brand_rows(identity.get("brands"), label="brand")
222
+ full_versions = _validated_ua_brand_rows(
223
+ identity.get("fullVersionList"),
224
+ label="full-version brand",
225
+ )
226
+ low_has_google = any(row["brand"] == "Google Chrome" for row in brands)
227
+ full_has_google = any(row["brand"] == "Google Chrome" for row in full_versions)
228
+ if low_has_google != full_has_google:
229
+ raise BrowserPolicyError("managed browser Chrome brand metadata is inconsistent")
230
+ if low_has_google:
231
+ return None
232
+
233
+ return {
234
+ "userAgent": user_agent,
235
+ "platform": navigator_platform,
236
+ "userAgentMetadata": {
237
+ "brands": _with_google_chrome_brand(brands, label="brand"),
238
+ "fullVersionList": _with_google_chrome_brand(
239
+ full_versions,
240
+ label="full-version brand",
241
+ ),
242
+ "platform": ua_platform,
243
+ "platformVersion": identity["platformVersion"],
244
+ "architecture": identity["architecture"],
245
+ "model": identity["model"],
246
+ "mobile": True,
247
+ "bitness": identity["bitness"],
248
+ "wow64": identity.get("wow64") is True,
249
+ },
250
+ }
251
+
252
+
253
+ _READ_NATIVE_BROWSER_IDENTITY = r"""async () => {
254
+ const data = navigator.userAgentData;
255
+ if (!data) return null;
256
+ let high = {};
257
+ try {
258
+ high = await data.getHighEntropyValues([
259
+ 'architecture', 'bitness', 'fullVersionList', 'model', 'platformVersion', 'wow64'
260
+ ]);
261
+ } catch (_) {
262
+ return null;
263
+ }
264
+ const copyBrands = value => Array.isArray(value)
265
+ ? value.map(row => ({brand: String(row.brand || ''), version: String(row.version || '')}))
266
+ : [];
267
+ return {
268
+ userAgent: String(navigator.userAgent || ''),
269
+ navigatorPlatform: String(navigator.platform || ''),
270
+ brands: copyBrands(data.brands),
271
+ fullVersionList: copyBrands(high.fullVersionList),
272
+ platform: String(data.platform || ''),
273
+ platformVersion: String(high.platformVersion || ''),
274
+ architecture: String(high.architecture || ''),
275
+ model: String(high.model || ''),
276
+ mobile: data.mobile === true,
277
+ bitness: String(high.bitness || ''),
278
+ wow64: high.wow64 === true,
279
+ };
280
+ }"""
281
+
282
+
283
+ _VERIFY_GOOGLE_CHROME_BRAND = r"""async () => {
284
+ const data = navigator.userAgentData;
285
+ // UA-CH is unavailable on Chrome's internal startup page. That is not an override failure:
286
+ // the same target exposes the metadata after its first secure navigation.
287
+ if (!data || !Array.isArray(data.brands)) return null;
288
+ let high = {};
289
+ try {
290
+ high = await data.getHighEntropyValues(['fullVersionList']);
291
+ } catch (_) {
292
+ return null;
293
+ }
294
+ if (!Array.isArray(high.fullVersionList)) return null;
295
+ return data.brands.some(row => row.brand === 'Google Chrome') &&
296
+ high.fullVersionList.some(row => row.brand === 'Google Chrome');
297
+ }"""
298
+
299
+
300
+ class Proxy:
301
+ def __init__(self, d: dict):
302
+ self.id = d.get("id")
303
+ self.url = d.get("url")
304
+ self.http_url = d.get("http_url") or self._with_scheme("http")
305
+ self.socks5_url = d.get("socks5_url") or self._with_scheme("socks5")
306
+ self.socks5h_url = d.get("socks5h_url") or self._with_scheme("socks5h")
307
+ self.protocols = tuple(d.get("protocols") or ("http", "socks5"))
308
+ # Kept for older callers. The endpoint itself auto-detects both protocols.
309
+ self.protocol = d.get("protocol")
310
+ self.status = d.get("status")
311
+ parsed = urlsplit(self.url or "")
312
+ self.host = parsed.hostname
313
+ self.port = parsed.port
314
+
315
+ def _with_scheme(self, scheme: str) -> Optional[str]:
316
+ if not self.url:
317
+ return None
318
+ parsed = urlsplit(self.url)
319
+ return urlunsplit((scheme, parsed.netloc, parsed.path, parsed.query, parsed.fragment))
320
+
321
+ def __repr__(self):
322
+ # Never put proxy credentials in a REPL/log merely because the object was printed.
323
+ return (
324
+ f"Proxy(host={self.host!r}, port={self.port!r}, "
325
+ f"protocols={self.protocols!r}, status={self.status!r})"
326
+ )
327
+
328
+
329
+ class Device:
330
+ def __init__(self, ai: "Aimlib", d: dict):
331
+ self._ai = ai
332
+ self.id = d["device_id"]
333
+ self.region = d.get("region")
334
+ self.carrier = d.get("carrier")
335
+ self.current_egress_ip = d.get("current_egress_ip")
336
+ self.proxy = Proxy(d["proxy"]) if d.get("proxy") else None
337
+ browser = d.get("browser") or {}
338
+ self.browser_available = browser.get("available")
339
+ lease = d.get("lease") or {}
340
+ self.lease_id = lease.get("id")
341
+ self.lease_ends_at = lease.get("ends_at")
342
+
343
+ def __repr__(self):
344
+ return f"Device(id={self.id!r}, region={self.region!r}, carrier={self.carrier!r})"
345
+
346
+ async def list_footprints(self) -> list:
347
+ """Return footprint choices available for this device.
348
+
349
+ Pass a returned slug to ``device.browser(footprint=...)`` or
350
+ ``session.set_footprint(...)``.
351
+ """
352
+ r = await self._ai._http.get(f"/v1/devices/{self.id}/footprints")
353
+ _raise_for_typed(r)
354
+ return r.json().get("footprints", [])
355
+
356
+ async def browser(self, footprint=None, ttl=None, idle_timeout=None, sticky=None) -> "BrowserSession":
357
+ """Start (provision) a managed-browser session on this phone. `footprint` is a clean-tier
358
+ slug (from list_footprints); omit it to leave the phone's persistent state authentic. Returns an
359
+ unconnected BrowserSession; `async with await device.browser(...) as b` connects + auto-stops.
360
+ connect() waits until the footprint is live on-device."""
361
+ body: dict = {}
362
+ if ttl is not None:
363
+ body["ttl"] = _ttl_seconds(ttl)
364
+ if idle_timeout is not None:
365
+ body["idle_timeout"] = _ttl_seconds(idle_timeout)
366
+ if footprint is not None:
367
+ body["footprint"] = footprint
368
+ # Retained only for call compatibility. Lease/session policy owns IP stickiness; the browser
369
+ # endpoint has never implemented this request field, so do not imply that it changes state.
370
+ _ = sticky
371
+ r = await self._ai._http.post(f"/v1/devices/{self.id}/browser", json=body)
372
+ _raise_for_typed(r)
373
+ session_data = r.json()
374
+ sess = BrowserSession(self._ai, self, session_data)
375
+ if footprint and session_data.get("desired_footprint") != footprint:
376
+ # Compatibility with servers predating atomic footprint selection. A current server
377
+ # validates and stores the footprint in the create transaction and echoes it above.
378
+ try:
379
+ fr = await self._ai._http.post(f"/v1/devices/{self.id}/footprint", json={"footprint": footprint})
380
+ _raise_for_typed(fr)
381
+ sess.desired_footprint = footprint
382
+ except BaseException as exc:
383
+ try:
384
+ await sess.stop()
385
+ except Exception as cleanup_exc: # noqa: BLE001
386
+ raise AimlibError(
387
+ "footprint selection failed and the new browser session could not be torn down"
388
+ ) from cleanup_exc
389
+ raise exc
390
+ elif footprint:
391
+ sess.desired_footprint = footprint
392
+ return sess
393
+
394
+ async def rotate_ip(self, wait: bool = True, timeout: float = 240) -> dict:
395
+ """Request a new cellular egress IP for this device.
396
+
397
+ Blocking mode returns a terminal operation dictionary and updates ``current_egress_ip``
398
+ when ``new_ip`` is present. With ``wait=False``, poll the returned ``operation_id`` through
399
+ ``ai.operations``. Leave at least 30 seconds between radio operations on one device.
400
+ """
401
+ body = {"wait": wait, "timeout_s": int(timeout)}
402
+ r = await self._ai._http.post(
403
+ f"/v1/devices/{self.id}/rotate-ip", json=body, timeout=timeout + 30
404
+ )
405
+ _raise_for_typed(r)
406
+ out = r.json()
407
+ if out.get("new_ip"):
408
+ self.current_egress_ip = out["new_ip"]
409
+ return out
410
+
411
+ async def switch_carrier(self, carrier: str, wait: bool = True, timeout: float = 200) -> dict:
412
+ """Switch to an available carrier identifier.
413
+
414
+ Accepted identifiers are ``tmobile``, ``att``, and ``verizon``; availability is
415
+ device-specific. Blocking mode returns a terminal operation dictionary. With
416
+ ``wait=False``, poll the returned ``operation_id`` through ``ai.operations``.
417
+ """
418
+ body = {"carrier": carrier, "wait": wait, "timeout_s": int(timeout)}
419
+ r = await self._ai._http.post(
420
+ f"/v1/devices/{self.id}/carrier", json=body, timeout=timeout + 30
421
+ )
422
+ _raise_for_typed(r)
423
+ out = r.json()
424
+ if out.get("status") == "succeeded" and out.get("carrier"):
425
+ self.carrier = out["carrier"]
426
+ if out.get("new_ip"):
427
+ self.current_egress_ip = out["new_ip"]
428
+ return out
429
+
430
+
431
+ class _Devices:
432
+ def __init__(self, ai: "Aimlib"):
433
+ self._ai = ai
434
+
435
+ async def list(self):
436
+ r = await self._ai._http.get("/v1/devices")
437
+ _raise_for_typed(r)
438
+ return [Device(self._ai, d) for d in r.json()]
439
+
440
+ async def get(self, device_id: str) -> Device:
441
+ for d in await self.list():
442
+ if d.id == device_id:
443
+ return d
444
+ raise AimlibError(f"device {device_id} not leased to this account")
445
+
446
+
447
+ class _Operations:
448
+ def __init__(self, ai: "Aimlib"):
449
+ self._ai = ai
450
+
451
+ async def get(self, operation_id: str) -> dict:
452
+ """Fetch a queued IP-rotation or carrier-switch operation owned by this account."""
453
+ r = await self._ai._http.get(f"/v1/operations/{operation_id}")
454
+ _raise_for_typed(r)
455
+ return r.json()
456
+
457
+ async def wait(
458
+ self,
459
+ operation_id: str,
460
+ timeout: float = 300,
461
+ poll_interval: float = 1.5,
462
+ ) -> dict:
463
+ """Poll an operation until it succeeds, fails, or reaches its server timeout."""
464
+ loop = asyncio.get_running_loop()
465
+ deadline = loop.time() + timeout
466
+ while True:
467
+ operation = await self.get(operation_id)
468
+ if operation.get("status") in {"succeeded", "failed", "timeout"}:
469
+ return operation
470
+ remaining = deadline - loop.time()
471
+ if remaining <= 0:
472
+ raise OperationTimeout("operation polling timed out")
473
+ await asyncio.sleep(min(poll_interval, remaining))
474
+
475
+
476
+ class BrowserSession:
477
+ def __init__(self, ai: "Aimlib", device: Device, data: dict):
478
+ self._ai = ai
479
+ self.device = device
480
+ self.id = data["session_id"]
481
+ self.status = data.get("status")
482
+ self.connect_url = data["connect_url"]
483
+ self.connect_token = data.get("connect_token")
484
+ self.max_tabs = int(data.get("max_tabs") or 5) # per-session tab cap (server-configured)
485
+ self._gave_initial = False
486
+ self.egress_ip = None
487
+ self.fingerprint = {}
488
+ self.desired_footprint = data.get("desired_footprint") or ""
489
+ self.applied_footprint = data.get("applied_footprint") or ""
490
+ self.expires_at = data.get("expires_at")
491
+ self.region = data.get("region")
492
+ self.ready_in_s = data.get("ready_in_s")
493
+ self._pw = None
494
+ self.browser = None # the live Playwright Browser once connected
495
+ self._identity_cdp_sessions = []
496
+ self._identity_pages: set[int] = set()
497
+ self._native_browser_identity = None
498
+
499
+ async def wait_until_ready(self, timeout: float = 180):
500
+ loop = asyncio.get_running_loop()
501
+ deadline = loop.time() + timeout
502
+ while loop.time() < deadline:
503
+ r = await self._ai._http.get(f"/v1/devices/{self.device.id}/browser")
504
+ if r.status_code == 404:
505
+ raise SessionExpiredError("session not found / expired")
506
+ _raise_for_typed(r)
507
+ data = r.json()
508
+ if data.get("session_id") != self.id:
509
+ raise SessionExpiredError("session was replaced by a newer browser session")
510
+ self.status = data.get("status")
511
+ self.applied_footprint = data.get("applied_footprint") or ""
512
+ self.expires_at = data.get("expires_at") or self.expires_at
513
+ self.egress_ip = data.get("egress_ip")
514
+ self.fingerprint = data.get("resolved_profile") or {}
515
+ # Do not connect until a requested browser footprint is fully active.
516
+ footprint_ok = (not self.desired_footprint) or (self.applied_footprint == self.desired_footprint)
517
+ if self.status in ("ready", "active", "idle") and footprint_ok:
518
+ return
519
+ if self.status in ("expiring", "gone"):
520
+ raise SessionExpiredError(f"session {self.status}")
521
+ if self.status == "failed":
522
+ raise AimlibError("session failed")
523
+ await asyncio.sleep(2)
524
+ raise SessionTimeout(f"session not ready within {timeout}s")
525
+
526
+ async def connect(self, timeout: float = 180):
527
+ """Wait until ready and connect the supported async browser client.
528
+
529
+ Transient connection failures are retried against the same remote session. Failed local
530
+ client instances are stopped before retrying.
531
+ """
532
+ if self.browser is not None:
533
+ try:
534
+ if self.browser.is_connected():
535
+ return self.browser
536
+ except (AttributeError, TypeError):
537
+ pass
538
+ await self._disconnect()
539
+
540
+ loop = asyncio.get_running_loop()
541
+ deadline = loop.time() + timeout
542
+ await self.wait_until_ready(timeout)
543
+ # The browser client expects the HTTP form of the returned connection endpoint.
544
+ disco = self.connect_url.replace("wss://", "https://").replace("ws://", "http://")
545
+ last_error: Exception | None = None
546
+ attempts = 3
547
+ for attempt in range(1, attempts + 1):
548
+ remaining = deadline - loop.time()
549
+ if remaining <= 0:
550
+ break
551
+ try:
552
+ # Check reachability before the browser client reduces transport failures to a
553
+ # generic exception. Authentication stays in a header and never enters a URL or log.
554
+ discovery = await self._ai._http.get(
555
+ disco.rstrip("/") + "/json/version",
556
+ headers={"Authorization": f"Bearer {self.connect_token}"},
557
+ timeout=max(1, min(remaining, 15)),
558
+ )
559
+ if discovery.status_code >= 500:
560
+ raise BrowserUnavailableError("remote browser is temporarily unavailable")
561
+ _raise_for_typed(discovery)
562
+ try:
563
+ ws_url = discovery.json().get("webSocketDebuggerUrl")
564
+ except (ValueError, AttributeError, TypeError):
565
+ ws_url = None
566
+ if not ws_url:
567
+ raise BrowserUnavailableError("remote browser returned invalid connection metadata")
568
+ async_playwright = _async_playwright()
569
+ self._pw = await async_playwright().start()
570
+ self.browser = await self._pw.chromium.connect_over_cdp(
571
+ disco,
572
+ headers={"Authorization": f"Bearer {self.connect_token}"},
573
+ timeout=max(1, int(min(remaining, 30) * 1000)),
574
+ )
575
+ for context in self.browser.contexts:
576
+ for page in context.pages:
577
+ await self._apply_page_identity(page)
578
+ return self.browser
579
+ except Exception as exc: # noqa: BLE001 - retry a bounded transient connection failure
580
+ last_error = exc
581
+ await self._disconnect()
582
+ if isinstance(exc, BrowserPolicyError):
583
+ raise
584
+ if attempt < attempts and loop.time() < deadline:
585
+ await asyncio.sleep(min(3, max(0, deadline - loop.time())))
586
+ if isinstance(last_error, BrowserUnavailableError):
587
+ raise BrowserUnavailableError(
588
+ f"remote browser did not become reachable after {attempts} attempts"
589
+ ) from last_error
590
+ failure_code = _browser_connect_failure_code(last_error) if last_error else "deadline_exhausted"
591
+ raise SessionTimeout(
592
+ f"remote browser did not become reachable within {timeout}s after {attempts} attempts "
593
+ f"({failure_code})"
594
+ ) from last_error
595
+
596
+ async def set_footprint(self, footprint: Optional[str], timeout: float = 120):
597
+ """Apply an available footprint and reconnect this session.
598
+
599
+ Pass ``None`` or an empty string to restore the device default. Select non-empty values from
600
+ ``device.list_footprints()``.
601
+ """
602
+ fp = footprint or ""
603
+ r = await self._ai._http.post(f"/v1/devices/{self.device.id}/footprint", json={"footprint": fp})
604
+ _raise_for_typed(r)
605
+ self.desired_footprint = fp
606
+ await self._disconnect() # Applying a footprint invalidates the existing connection.
607
+ loop = asyncio.get_running_loop()
608
+ deadline = loop.time() + timeout
609
+ while loop.time() < deadline:
610
+ r = await self._ai._http.get(f"/v1/devices/{self.device.id}/browser")
611
+ if r.status_code == 404:
612
+ raise SessionExpiredError("session not found / expired")
613
+ data = r.json()
614
+ self.applied_footprint = data.get("applied_footprint") or ""
615
+ if self.applied_footprint == fp and data.get("status") in ("ready", "active", "idle"):
616
+ await self.connect(timeout=timeout) # transparent reconnect to the same session_id
617
+ return
618
+ await asyncio.sleep(2)
619
+ raise SessionTimeout(f"footprint {fp!r} not applied within {timeout}s")
620
+
621
+ async def _disconnect(self):
622
+ for cdp_session in reversed(self._identity_cdp_sessions):
623
+ try:
624
+ await cdp_session.detach()
625
+ except Exception: # noqa: BLE001
626
+ pass
627
+ self._identity_cdp_sessions = []
628
+ self._identity_pages.clear()
629
+ self._native_browser_identity = None
630
+ for closer in (lambda: self.browser and self.browser.close(), lambda: self._pw and self._pw.stop()):
631
+ try:
632
+ c = closer()
633
+ if c is not None:
634
+ await c
635
+ except Exception: # noqa: BLE001
636
+ pass
637
+ self.browser = None
638
+ self._pw = None
639
+ self._gave_initial = False
640
+
641
+ async def _apply_page_identity(self, page):
642
+ """Brand ChromePublic coherently without changing the phone's native version or model."""
643
+ page_key = id(page)
644
+ if page_key in self._identity_pages:
645
+ return
646
+ cdp_session = None
647
+ try:
648
+ identity = await self._native_identity_for_page(page)
649
+ override = _google_chrome_user_agent_override(identity)
650
+ if override is not None:
651
+ cdp_session = await page.context.new_cdp_session(page)
652
+ await cdp_session.send("Emulation.setUserAgentOverride", override)
653
+ verified = await page.evaluate(_VERIFY_GOOGLE_CHROME_BRAND)
654
+ # Internal/about:blank startup pages cannot expose UA-CH. The override has already
655
+ # been validated on the secure bootstrap tab below; a definitive false result on a
656
+ # page that does expose UA-CH still fails closed.
657
+ if verified is False:
658
+ raise BrowserPolicyError("managed browser Chrome branding did not apply")
659
+ self._identity_cdp_sessions.append(cdp_session)
660
+ cdp_session = None
661
+ self._identity_pages.add(page_key)
662
+ except BrowserPolicyError:
663
+ raise
664
+ except Exception as exc: # noqa: BLE001 - never expose CDP endpoint or device details
665
+ raise BrowserPolicyError("managed browser identity policy could not be applied") from exc
666
+ finally:
667
+ if cdp_session is not None:
668
+ try:
669
+ await cdp_session.detach()
670
+ except Exception: # noqa: BLE001
671
+ pass
672
+
673
+ async def _native_identity_for_page(self, page):
674
+ """Read UA-CH in a secure first-party context when Chrome's startup tab cannot expose it."""
675
+ if self._native_browser_identity is not None:
676
+ return self._native_browser_identity
677
+ identity = await page.evaluate(_READ_NATIVE_BROWSER_IDENTITY)
678
+ if identity is None:
679
+ bootstrap_page = None
680
+ bootstrap_cdp_session = None
681
+ try:
682
+ bootstrap_page = await page.context.new_page()
683
+ await bootstrap_page.goto(
684
+ "https://docs.aimlib.com/",
685
+ wait_until="domcontentloaded",
686
+ timeout=30_000,
687
+ )
688
+ identity = await bootstrap_page.evaluate(_READ_NATIVE_BROWSER_IDENTITY)
689
+ override = _google_chrome_user_agent_override(identity)
690
+ if override is not None:
691
+ bootstrap_cdp_session = await page.context.new_cdp_session(
692
+ bootstrap_page
693
+ )
694
+ await bootstrap_cdp_session.send(
695
+ "Emulation.setUserAgentOverride",
696
+ override,
697
+ )
698
+ verified = await bootstrap_page.evaluate(
699
+ _VERIFY_GOOGLE_CHROME_BRAND
700
+ )
701
+ if verified is not True:
702
+ raise BrowserPolicyError(
703
+ "managed browser Chrome branding did not apply"
704
+ )
705
+ except Exception as exc: # noqa: BLE001 - keep URL/session details out of the error
706
+ if isinstance(exc, BrowserPolicyError):
707
+ raise
708
+ raise BrowserPolicyError(
709
+ "managed browser identity bootstrap was unavailable"
710
+ ) from exc
711
+ finally:
712
+ if bootstrap_cdp_session is not None:
713
+ try:
714
+ await bootstrap_cdp_session.detach()
715
+ except Exception: # noqa: BLE001
716
+ pass
717
+ if bootstrap_page is not None:
718
+ try:
719
+ await bootstrap_page.close()
720
+ except Exception: # noqa: BLE001
721
+ pass
722
+ if identity is None:
723
+ raise BrowserPolicyError("managed browser identity metadata is unavailable")
724
+ self._native_browser_identity = identity
725
+ return identity
726
+
727
+ async def disconnect(self):
728
+ """Disconnect this local SDK client without stopping the remote session.
729
+
730
+ A later connect() or new_page() reconnects to the same session. Use stop() when the remote
731
+ browser should be torn down.
732
+ """
733
+ await self._disconnect()
734
+
735
+ async def new_page(self):
736
+ """Open a tab. Reuses the session's initial tab on the first call, then opens new tabs up to
737
+ the per-session limit (self.max_tabs, server-configured). Raises TabLimitError past the
738
+ limit — open a separate session for more parallelism (tabs share one IP + fingerprint)."""
739
+ b = self.browser
740
+ if b is not None:
741
+ try:
742
+ if not b.is_connected():
743
+ b = None
744
+ except (AttributeError, TypeError):
745
+ pass
746
+ if b is None:
747
+ b = await self.connect()
748
+ ctx = b.contexts[0] if b.contexts else await b.new_context()
749
+ if not self._gave_initial and ctx.pages:
750
+ self._gave_initial = True
751
+ page = ctx.pages[0]
752
+ await self._apply_page_identity(page)
753
+ return page
754
+ if len(ctx.pages) >= self.max_tabs:
755
+ raise TabLimitError(
756
+ f"tab limit reached ({self.max_tabs} per session); close a tab or start a separate session"
757
+ )
758
+ page = await ctx.new_page()
759
+ await self._apply_page_identity(page)
760
+ return page
761
+
762
+ async def wait_until_stopped(self, timeout: float = 120):
763
+ """Wait until the service confirms this session is no longer live."""
764
+ loop = asyncio.get_running_loop()
765
+ deadline = loop.time() + timeout
766
+ while loop.time() < deadline:
767
+ r = await self._ai._http.get(f"/v1/devices/{self.device.id}/browser")
768
+ if r.status_code == 404:
769
+ self.status = "gone"
770
+ return
771
+ _raise_for_typed(r)
772
+ data = r.json()
773
+ if data.get("session_id") != self.id or data.get("status") in ("gone", "failed"):
774
+ self.status = "gone"
775
+ return
776
+ self.status = data.get("status") or self.status
777
+ await asyncio.sleep(2)
778
+ raise SessionTimeout(f"session teardown not confirmed within {timeout}s")
779
+
780
+ async def stop(self, wait: bool = True, timeout: float = 120):
781
+ """Close the local driver, request on-phone teardown, and optionally wait for confirmation."""
782
+ await self._disconnect()
783
+ try:
784
+ r = await self._ai._http.delete(f"/v1/devices/{self.device.id}/browser")
785
+ except Exception as exc: # noqa: BLE001
786
+ raise AimlibError("could not request browser teardown") from exc
787
+ if r.status_code == 404:
788
+ self.status = "gone"
789
+ return
790
+ _raise_for_typed(r)
791
+ self.status = "expiring"
792
+ if wait:
793
+ await self.wait_until_stopped(timeout)
794
+
795
+ async def __aenter__(self):
796
+ try:
797
+ await self.connect()
798
+ return self
799
+ except BaseException as exc:
800
+ try:
801
+ await self.stop()
802
+ except Exception as cleanup_exc: # noqa: BLE001
803
+ raise AimlibError(
804
+ f"browser connection failed: {exc}; automatic teardown was not confirmed"
805
+ ) from cleanup_exc
806
+ raise exc
807
+
808
+ async def __aexit__(self, exc_type, exc, traceback):
809
+ try:
810
+ await self.stop()
811
+ except Exception as cleanup_exc: # noqa: BLE001
812
+ if exc is not None:
813
+ raise AimlibError(
814
+ f"browser operation failed: {exc}; automatic teardown was not confirmed"
815
+ ) from cleanup_exc
816
+ raise
817
+
818
+
819
+ class Ticket:
820
+ """A support ticket. A ticket is only COMPLETE (`status == "closed"`) once BOTH you and the
821
+ operator have closed it; any new message reopens it. `awaiting_close_from` says who still needs
822
+ to close ('customer', 'operator', or 'both').
823
+
824
+ `acknowledged` is True once a support agent has opened or replied to the ticket — i.e. an
825
+ investigation is underway and you can check back later. `status_detail` is a human-readable
826
+ summary of where it stands (e.g. "A support agent is reviewing your ticket")."""
827
+
828
+ def __init__(self, ai: "Aimlib", d: dict):
829
+ self._ai = ai
830
+ self.id = d["id"]
831
+ self.subject = d.get("subject")
832
+ self.status = d.get("status")
833
+ self.acknowledged = bool(d.get("acknowledged"))
834
+ self.acknowledged_at = d.get("acknowledged_at")
835
+ self.status_detail = d.get("status_detail")
836
+ self.customer_closed = bool(d.get("customer_closed"))
837
+ self.operator_closed = bool(d.get("operator_closed"))
838
+ self.awaiting_close_from = d.get("awaiting_close_from")
839
+ self.messages = d.get("messages", []) # [{author: 'customer'|'operator', body, created_at}]
840
+ self.created_at = d.get("created_at")
841
+ self.updated_at = d.get("updated_at")
842
+ self.closed_at = d.get("closed_at")
843
+
844
+ @property
845
+ def complete(self) -> bool:
846
+ return self.status == "closed"
847
+
848
+ async def reply(self, body: str) -> "Ticket":
849
+ return await self._ai.tickets.reply(self.id, body)
850
+
851
+ async def close(self) -> "Ticket":
852
+ return await self._ai.tickets.close(self.id)
853
+
854
+ def __repr__(self):
855
+ return f"Ticket(id={self.id!r}, subject={self.subject!r}, status={self.status!r})"
856
+
857
+
858
+ class _Tickets:
859
+ def __init__(self, ai: "Aimlib"):
860
+ self._ai = ai
861
+
862
+ async def create(self, subject: str, body: str) -> Ticket:
863
+ """File a new support ticket (subject + first message)."""
864
+ r = await self._ai._http.post("/v1/tickets", json={"subject": subject, "body": body})
865
+ _raise_for_typed(r)
866
+ return Ticket(self._ai, r.json())
867
+
868
+ async def list(self) -> "list[Ticket]":
869
+ r = await self._ai._http.get("/v1/tickets")
870
+ _raise_for_typed(r)
871
+ return [Ticket(self._ai, d) for d in r.json()]
872
+
873
+ async def get(self, ticket_id: str) -> Ticket:
874
+ """Fetch one ticket including its full message thread."""
875
+ r = await self._ai._http.get(f"/v1/tickets/{ticket_id}")
876
+ _raise_for_typed(r)
877
+ return Ticket(self._ai, r.json())
878
+
879
+ async def reply(self, ticket_id: str, body: str) -> Ticket:
880
+ """Post a message on a ticket (reopens it if it was closed)."""
881
+ r = await self._ai._http.post(f"/v1/tickets/{ticket_id}/messages", json={"body": body})
882
+ _raise_for_typed(r)
883
+ return Ticket(self._ai, r.json())
884
+
885
+ async def close(self, ticket_id: str) -> Ticket:
886
+ """Mark the ticket closed from your side. It's only complete once the operator closes too."""
887
+ r = await self._ai._http.post(f"/v1/tickets/{ticket_id}/close")
888
+ _raise_for_typed(r)
889
+ return Ticket(self._ai, r.json())
890
+
891
+
892
+ class Aimlib:
893
+ def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None, timeout: float = 60):
894
+ self.api_key = api_key or os.environ.get("AIMLIB_API_KEY")
895
+ if not self.api_key:
896
+ raise AimlibError("no API key (pass api_key= or set AIMLIB_API_KEY)")
897
+ # Use the regional customer API URL assigned to the account unless explicitly overridden.
898
+ self.base_url = (base_url or os.environ.get("AIMLIB_BASE_URL", "https://uswest1.aimlib.com")).rstrip("/")
899
+ self._http = httpx.AsyncClient(
900
+ base_url=self.base_url, timeout=timeout,
901
+ headers={"Authorization": f"Bearer {self.api_key}"},
902
+ )
903
+ self.devices = _Devices(self)
904
+ self.operations = _Operations(self)
905
+ self.tickets = _Tickets(self)
906
+
907
+ async def device(self, device_id: str) -> Device:
908
+ return await self.devices.get(device_id)
909
+
910
+ async def aclose(self):
911
+ await self._http.aclose()
912
+
913
+ async def __aenter__(self):
914
+ return self
915
+
916
+ async def __aexit__(self, *exc):
917
+ await self.aclose()
@@ -0,0 +1,112 @@
1
+ Metadata-Version: 2.4
2
+ Name: aimlib
3
+ Version: 0.4.2
4
+ Summary: Python SDK for aimlib mobile proxies and remote-browser sessions
5
+ Author: aimlib
6
+ License-Expression: MIT
7
+ Project-URL: Documentation, https://docs.aimlib.com/
8
+ Project-URL: Changelog, https://github.com/rukouai/aimlib-python/blob/main/CHANGELOG.md
9
+ Project-URL: Issues, https://github.com/rukouai/aimlib-python/issues
10
+ Project-URL: Source, https://github.com/rukouai/aimlib-python
11
+ Keywords: aimlib,mobile-proxy,proxy,remote-browser,asyncio
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Framework :: AsyncIO
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Topic :: Internet :: Proxy Servers
24
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
+ Requires-Python: >=3.10
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Requires-Dist: httpx<1,>=0.27
29
+ Provides-Extra: browser
30
+ Requires-Dist: patchright==1.61.2; extra == "browser"
31
+ Dynamic: license-file
32
+
33
+ # aimlib Python SDK
34
+
35
+ [![PyPI](https://img.shields.io/pypi/v/aimlib)](https://pypi.org/project/aimlib/)
36
+ [![Python](https://img.shields.io/pypi/pyversions/aimlib)](https://pypi.org/project/aimlib/)
37
+ [![CI](https://github.com/rukouai/aimlib-python/actions/workflows/ci.yml/badge.svg)](https://github.com/rukouai/aimlib-python/actions/workflows/ci.yml)
38
+
39
+ Asynchronous Python client for aimlib mobile proxies, on-device remote-browser sessions, radio
40
+ operations, and support tickets. Python 3.10 or newer is required.
41
+
42
+ ## Install
43
+
44
+ For proxy, device, radio-operation, and support APIs:
45
+
46
+ ```sh
47
+ python -m pip install --upgrade aimlib
48
+ ```
49
+
50
+ Include managed remote-browser support when needed:
51
+
52
+ ```sh
53
+ python -m pip install --upgrade "aimlib[browser]"
54
+ ```
55
+
56
+ The browser extra connects to Chromium on the leased phone. It does not download or launch a browser
57
+ binary on your computer.
58
+
59
+ ## Configure
60
+
61
+ ```sh
62
+ export AIMLIB_API_KEY="<api-key>"
63
+ export AIMLIB_BASE_URL="https://uswest1.aimlib.com"
64
+ ```
65
+
66
+ API keys, credential-bearing proxy URLs, and browser connection tokens are secrets. Do not print or
67
+ log them.
68
+
69
+ ## Example
70
+
71
+ ```python
72
+ from aimlib import Aimlib
73
+
74
+ async with Aimlib() as ai:
75
+ device = (await ai.devices.list())[0]
76
+
77
+ http_proxy = device.proxy.http_url
78
+ socks_remote_dns_proxy = device.proxy.socks5h_url
79
+
80
+ async with await device.browser(ttl="10m") as session:
81
+ page = await session.new_page()
82
+ await page.goto("https://example.com")
83
+ print(await page.title())
84
+ ```
85
+
86
+ Only devices in your active rentals are returned. A remote browser can be created only after a
87
+ rental is active and the phone has been assigned to it.
88
+
89
+ ## Main APIs
90
+
91
+ - `ai.devices.list()` and `ai.device(id)` return devices in active rentals.
92
+ - `device.proxy.http_url`, `.socks5_url`, and `.socks5h_url` address the same proxy host and port.
93
+ - `device.browser()` creates a remote-browser session for the active rental.
94
+ - `session.new_page()`, `.disconnect()`, `.connect()`, and `.stop()` manage that session.
95
+ - `device.rotate_ip()` and `device.switch_carrier()` support blocking or queued operation modes.
96
+ - `ai.operations.get(id)` and `.wait(id)` poll queued radio operations.
97
+ - `ai.tickets` creates, lists, reads, replies to, and closes support tickets.
98
+
99
+ ## Development
100
+
101
+ ```sh
102
+ python -m pip install -e ".[browser]"
103
+ python -m unittest discover -s tests -v
104
+ ```
105
+
106
+ See the [customer documentation](https://docs.aimlib.com/) for the complete API contract, limits,
107
+ security policy, and examples. Release notes are kept in the
108
+ [changelog](https://github.com/rukouai/aimlib-python/blob/main/CHANGELOG.md).
109
+
110
+ Report security issues privately as described in the
111
+ [security policy](https://github.com/rukouai/aimlib-python/blob/main/SECURITY.md). Never include API
112
+ keys, credential-bearing proxy URLs, browser connection tokens, or customer data in an issue or log.
@@ -0,0 +1,6 @@
1
+ aimlib/__init__.py,sha256=q-Eztna4ZIm3JqZ9GqWVoWAXzZzp3oiaIeyJ33OjfB8,38610
2
+ aimlib-0.4.2.dist-info/licenses/LICENSE,sha256=E4gr--9s1ym-JdC3PDjiov8NhCLGbpkLhOJev6cVT7s,1063
3
+ aimlib-0.4.2.dist-info/METADATA,sha256=z8MhElu8CYdjnPGJvGb6__C2HBbK5U695AGhnMKQuWU,4201
4
+ aimlib-0.4.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
5
+ aimlib-0.4.2.dist-info/top_level.txt,sha256=Pl-OMtWVob-P5rBf_AS2kwWal-lMaDMpPXq08JijDjA,7
6
+ aimlib-0.4.2.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 aimlib
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ aimlib