scalebrowser 0.2.0__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.
scalebrowser/_sync.py ADDED
@@ -0,0 +1,604 @@
1
+ """Synchronous ergonomics over the async core.
2
+
3
+ A single background daemon thread runs an asyncio loop; the sync client and CDP
4
+ session drive the async implementations through it via
5
+ ``run_coroutine_threadsafe``. There is no duplicated endpoint logic — every call
6
+ forwards to :class:`AsyncScalebrowserClient` / :class:`CdpSession`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import threading
13
+ from contextlib import contextmanager
14
+ from typing import Any, Coroutine, Iterator, Mapping, Optional, Sequence, TypeVar, Union
15
+
16
+ import httpx
17
+
18
+ from .cdp import CdpSession, EventCallback
19
+ from .client import DEFAULT_BASE_URL, AsyncScalebrowserClient
20
+ from .models import (
21
+ RunStep,
22
+ CheckProxyConfigBody,
23
+ CredentialBundle,
24
+ CredentialImportResult,
25
+ CredentialMeta,
26
+ CreateGroupBody,
27
+ CreatePresetBody,
28
+ CreateProfileBody,
29
+ CreateProxyBody,
30
+ AuditStatus,
31
+ Extension,
32
+ ExtensionsResult,
33
+ Group,
34
+ Metrics,
35
+ PersonaConstraintOptions,
36
+ Preset,
37
+ Profile,
38
+ Proxy,
39
+ ProxyCheckResult,
40
+ RevealedCredential,
41
+ SessionExportResult,
42
+ StartProfileResult,
43
+ StopProfileResult,
44
+ UpdatePresetBody,
45
+ UpdateProfileBody,
46
+ UpdateProxyBody,
47
+ VaultStatus,
48
+ )
49
+
50
+ from .models_control import (
51
+ Account,
52
+ ArtifactBytes,
53
+ ArtifactPutResult,
54
+ HealthStatus,
55
+ InterruptionLockView,
56
+ InterruptionRuleRow,
57
+ ReadyStatus,
58
+ SetInterruptionLockBody,
59
+ SetInterruptionRuleBody,
60
+ )
61
+ from .models_identity import (
62
+ BindInboxBody,
63
+ Inbox,
64
+ InboxBindings,
65
+ PasskeyRow,
66
+ PutInboxBody,
67
+ RevealCookiesBody,
68
+ RevealCookiesResult,
69
+ )
70
+ from .models_runs import ActivitySnapshot, AgentRun
71
+
72
+ T = TypeVar("T")
73
+
74
+
75
+ class _Portal:
76
+ """A dedicated event loop running on a background daemon thread."""
77
+
78
+ def __init__(self) -> None:
79
+ self._loop = asyncio.new_event_loop()
80
+ self._thread = threading.Thread(
81
+ target=self._loop.run_forever, name="scalebrowser-portal", daemon=True
82
+ )
83
+ self._thread.start()
84
+
85
+ def run(self, coro: Coroutine[Any, Any, T]) -> T:
86
+ return asyncio.run_coroutine_threadsafe(coro, self._loop).result()
87
+
88
+ def close(self) -> None:
89
+ self._loop.call_soon_threadsafe(self._loop.stop)
90
+ self._thread.join(timeout=5)
91
+ if not self._loop.is_running():
92
+ self._loop.close()
93
+
94
+
95
+ class SyncCdpSession:
96
+ """Synchronous wrapper around :class:`CdpSession`."""
97
+
98
+ def __init__(self, inner: CdpSession, portal: _Portal) -> None:
99
+ self._inner = inner
100
+ self._portal = portal
101
+
102
+ @property
103
+ def session_id(self) -> Optional[str]:
104
+ return self._inner.session_id
105
+
106
+ def send(self, method: str, params: Optional[dict[str, Any]] = None) -> dict[str, Any]:
107
+ return self._portal.run(self._inner.send(method, params))
108
+
109
+ def on(self, method: str, callback: EventCallback) -> Any:
110
+ return self._inner.on(method, callback)
111
+
112
+ def attach_to_page(
113
+ self, target_id: Optional[str] = None, *, create: bool = False, url: Optional[str] = None
114
+ ) -> "SyncCdpSession":
115
+ child = self._portal.run(self._inner.attach_to_page(target_id, create=create, url=url))
116
+ return SyncCdpSession(child, self._portal)
117
+
118
+ def navigate(self, url: str, *, wait: bool = True, timeout: float = 30.0) -> dict[str, Any]:
119
+ return self._portal.run(self._inner.navigate(url, wait=wait, timeout=timeout))
120
+
121
+ def evaluate(
122
+ self,
123
+ expression: str,
124
+ *,
125
+ await_promise: bool = True,
126
+ return_by_value: bool = True,
127
+ isolated: bool = False,
128
+ ) -> Any:
129
+ return self._portal.run(
130
+ self._inner.evaluate(
131
+ expression,
132
+ await_promise=await_promise,
133
+ return_by_value=return_by_value,
134
+ isolated=isolated,
135
+ )
136
+ )
137
+
138
+ def create_isolated_world(self, frame_id: Optional[str] = None, world_name: str = "__sb") -> int:
139
+ return self._portal.run(self._inner.create_isolated_world(frame_id, world_name))
140
+
141
+ def humanize_move(self, x: float, y: float) -> None:
142
+ self._portal.run(self._inner.humanize_move(x, y))
143
+
144
+ def humanize_click(self, x: float, y: float, *, button: str = "left", click_count: int = 1) -> None:
145
+ self._portal.run(self._inner.humanize_click(x, y, button=button, click_count=click_count))
146
+
147
+ def humanize_type(self, text: str) -> None:
148
+ self._portal.run(self._inner.humanize_type(text))
149
+
150
+ def humanize_scroll(self, x: float, y: float, *, delta_x: float = 0, delta_y: float = 0) -> None:
151
+ self._portal.run(self._inner.humanize_scroll(x, y, delta_x=delta_x, delta_y=delta_y))
152
+
153
+ def close(self) -> None:
154
+ self._portal.run(self._inner.close())
155
+
156
+ def __enter__(self) -> "SyncCdpSession":
157
+ return self
158
+
159
+ def __exit__(self, *exc: object) -> None:
160
+ self.close()
161
+
162
+
163
+ class ScalebrowserClient:
164
+ """Synchronous client — mirrors :class:`AsyncScalebrowserClient` one-to-one."""
165
+
166
+ def __init__(
167
+ self,
168
+ base_url: str = DEFAULT_BASE_URL,
169
+ token: Optional[str] = None,
170
+ *,
171
+ timeout: float = 30.0,
172
+ transport: Optional[httpx.AsyncBaseTransport] = None,
173
+ http_client: Optional[httpx.AsyncClient] = None,
174
+ ) -> None:
175
+ self._portal = _Portal()
176
+ self._async = AsyncScalebrowserClient(
177
+ base_url, token, timeout=timeout, transport=transport, http_client=http_client
178
+ )
179
+
180
+ @property
181
+ def async_client(self) -> AsyncScalebrowserClient:
182
+ return self._async
183
+
184
+ def __repr__(self) -> str:
185
+ masked = "***" if self._async.transport.token else None
186
+ return f"ScalebrowserClient(base_url={self._async.transport.base_url!r}, token={masked!r})"
187
+
188
+ # ── profiles ──────────────────────────────────────────────────────────────
189
+
190
+ def list_profiles(
191
+ self,
192
+ *,
193
+ group: Optional[str] = None,
194
+ state: Optional[str] = None,
195
+ q: Optional[str] = None,
196
+ limit: Optional[int] = None,
197
+ offset: Optional[int] = None,
198
+ ) -> list[Profile]:
199
+ return self._portal.run(
200
+ self._async.list_profiles(group=group, state=state, q=q, limit=limit, offset=offset)
201
+ )
202
+
203
+ def get_profile(self, profile_id: str) -> Profile:
204
+ return self._portal.run(self._async.get_profile(profile_id))
205
+
206
+ def create_profile(self, body: Union[CreateProfileBody, Mapping[str, Any]]) -> Profile:
207
+ return self._portal.run(self._async.create_profile(body))
208
+
209
+ def update_profile(
210
+ self, profile_id: str, body: Union[UpdateProfileBody, Mapping[str, Any]]
211
+ ) -> Profile:
212
+ return self._portal.run(self._async.update_profile(profile_id, body))
213
+
214
+ def delete_profile(self, profile_id: str) -> None:
215
+ self._portal.run(self._async.delete_profile(profile_id))
216
+
217
+ def start_profile(self, profile_id: str, *, headless: Optional[bool] = None) -> StartProfileResult:
218
+ return self._portal.run(self._async.start_profile(profile_id, headless=headless))
219
+
220
+ def stop_profile(self, profile_id: str) -> StopProfileResult:
221
+ return self._portal.run(self._async.stop_profile(profile_id))
222
+
223
+ def list_profile_ids(
224
+ self,
225
+ *,
226
+ group: Optional[str] = None,
227
+ state: Optional[str] = None,
228
+ q: Optional[str] = None,
229
+ sort: Optional[str] = None,
230
+ order: Optional[str] = None,
231
+ ) -> dict[str, Any]:
232
+ """Every id matching the filters, unpaged — what "act on all matches" needs."""
233
+ return self._portal.run(
234
+ self._async.list_profile_ids(group=group, state=state, q=q, sort=sort, order=order)
235
+ )
236
+
237
+ # ── bulk ──────────────────────────────────────────────────────────────────
238
+
239
+ def bulk_create_profiles(
240
+ self,
241
+ preset_id: str,
242
+ count: int,
243
+ *,
244
+ name_prefix: Optional[str] = None,
245
+ group_id: Optional[str] = None,
246
+ ) -> list[Profile]:
247
+ return self._portal.run(
248
+ self._async.bulk_create_profiles(
249
+ preset_id, count, name_prefix=name_prefix, group_id=group_id
250
+ )
251
+ )
252
+
253
+ def bulk_start(self, ids: Sequence[str]) -> Any:
254
+ return self._portal.run(self._async.bulk_start(ids))
255
+
256
+ def bulk_stop(self, ids: Sequence[str]) -> Any:
257
+ return self._portal.run(self._async.bulk_stop(ids))
258
+
259
+ def bulk_delete(self, ids: Sequence[str]) -> Any:
260
+ return self._portal.run(self._async.bulk_delete(ids))
261
+
262
+ def bulk_assign_proxy(self, ids: Sequence[str], proxy_id: str) -> Any:
263
+ return self._portal.run(self._async.bulk_assign_proxy(ids, proxy_id))
264
+
265
+ def bulk_assign_extensions(self, ids: Sequence[str], ext_refs: Sequence[str]) -> Any:
266
+ return self._portal.run(self._async.bulk_assign_extensions(ids, ext_refs))
267
+
268
+ # ── groups ────────────────────────────────────────────────────────────────
269
+
270
+ def list_groups(self) -> list[Group]:
271
+ return self._portal.run(self._async.list_groups())
272
+
273
+ def create_group(self, body: Union[CreateGroupBody, Mapping[str, Any]]) -> Group:
274
+ return self._portal.run(self._async.create_group(body))
275
+
276
+ def get_group(self, group_id: str) -> Group:
277
+ return self._portal.run(self._async.get_group(group_id))
278
+
279
+ def update_group(self, group_id: str, body: Mapping[str, Any]) -> Group:
280
+ return self._portal.run(self._async.update_group(group_id, body))
281
+
282
+ def delete_group(self, group_id: str) -> None:
283
+ self._portal.run(self._async.delete_group(group_id))
284
+
285
+ # ── presets ───────────────────────────────────────────────────────────────
286
+
287
+ def list_presets(self) -> list[Preset]:
288
+ return self._portal.run(self._async.list_presets())
289
+
290
+ def create_preset(self, body: Union[CreatePresetBody, Mapping[str, Any]]) -> Preset:
291
+ return self._portal.run(self._async.create_preset(body))
292
+
293
+ def get_preset(self, preset_id: str) -> Preset:
294
+ return self._portal.run(self._async.get_preset(preset_id))
295
+
296
+ def update_preset(
297
+ self, preset_id: str, body: Union[UpdatePresetBody, Mapping[str, Any]]
298
+ ) -> Preset:
299
+ return self._portal.run(self._async.update_preset(preset_id, body))
300
+
301
+ def delete_preset(self, preset_id: str) -> None:
302
+ self._portal.run(self._async.delete_preset(preset_id))
303
+
304
+ def get_persona_constraints(self) -> PersonaConstraintOptions:
305
+ return self._portal.run(self._async.get_persona_constraints())
306
+
307
+ # ── proxies ───────────────────────────────────────────────────────────────
308
+
309
+ def list_proxies(self) -> list[Proxy]:
310
+ return self._portal.run(self._async.list_proxies())
311
+
312
+ def create_proxy(self, body: Union[CreateProxyBody, Mapping[str, Any]]) -> Proxy:
313
+ return self._portal.run(self._async.create_proxy(body))
314
+
315
+ def get_proxy(self, proxy_id: str) -> Proxy:
316
+ return self._portal.run(self._async.get_proxy(proxy_id))
317
+
318
+ def update_proxy(self, proxy_id: str, body: Union[UpdateProxyBody, Mapping[str, Any]]) -> Proxy:
319
+ return self._portal.run(self._async.update_proxy(proxy_id, body))
320
+
321
+ def delete_proxy(self, proxy_id: str) -> None:
322
+ self._portal.run(self._async.delete_proxy(proxy_id))
323
+
324
+ def check_proxy(self, proxy_id: str) -> ProxyCheckResult:
325
+ return self._portal.run(self._async.check_proxy(proxy_id))
326
+
327
+ def check_proxy_config(
328
+ self, body: Union[CheckProxyConfigBody, Mapping[str, Any]]
329
+ ) -> ProxyCheckResult:
330
+ return self._portal.run(self._async.check_proxy_config(body))
331
+
332
+ # ── live detector audit ───────────────────────────────────────────────────
333
+
334
+ def start_audit(self, profile_id: str) -> AuditStatus:
335
+ return self._portal.run(self._async.start_audit(profile_id))
336
+
337
+ def get_audit(self, profile_id: str) -> AuditStatus:
338
+ return self._portal.run(self._async.get_audit(profile_id))
339
+
340
+ # ── extension library (daemon-wide) ───────────────────────────────────────
341
+
342
+ def list_library_extensions(self) -> list[Extension]:
343
+ return self._portal.run(self._async.list_library_extensions())
344
+
345
+ def upload_extension(self, crx: bytes) -> Extension:
346
+ return self._portal.run(self._async.upload_extension(crx))
347
+
348
+ def get_library_extension(self, ext_id: str) -> Extension:
349
+ return self._portal.run(self._async.get_library_extension(ext_id))
350
+
351
+ def delete_library_extension(self, ext_id: str) -> None:
352
+ self._portal.run(self._async.delete_library_extension(ext_id))
353
+
354
+ def delete_library_extension_version(self, ext_id: str, version: str) -> None:
355
+ self._portal.run(self._async.delete_library_extension_version(ext_id, version))
356
+
357
+ # ── extensions per profile (ext_ref is a LIBRARY ID, not a path) ──────────
358
+
359
+ def list_extensions(self, profile_id: str) -> ExtensionsResult:
360
+ return self._portal.run(self._async.list_extensions(profile_id))
361
+
362
+ def attach_extension(self, profile_id: str, ext_ref: str) -> ExtensionsResult:
363
+ return self._portal.run(self._async.attach_extension(profile_id, ext_ref))
364
+
365
+ def detach_extension(self, profile_id: str, ext_ref: str) -> ExtensionsResult:
366
+ return self._portal.run(self._async.detach_extension(profile_id, ext_ref))
367
+
368
+ # ── platform credentials ──────────────────────────────────────────────────
369
+
370
+ def list_credentials(self, profile_id: str) -> list[CredentialMeta]:
371
+ return self._portal.run(self._async.list_credentials(profile_id))
372
+
373
+ def put_credential(
374
+ self,
375
+ profile_id: str,
376
+ platform: str,
377
+ *,
378
+ username: Optional[str] = None,
379
+ password: Optional[str] = None,
380
+ totp_secret: Optional[str] = None,
381
+ login_url: Optional[str] = None,
382
+ ) -> CredentialMeta:
383
+ return self._portal.run(
384
+ self._async.put_credential(
385
+ profile_id,
386
+ platform,
387
+ username=username,
388
+ password=password,
389
+ totp_secret=totp_secret,
390
+ login_url=login_url,
391
+ )
392
+ )
393
+
394
+ def delete_credential(self, profile_id: str, platform: str) -> None:
395
+ return self._portal.run(self._async.delete_credential(profile_id, platform))
396
+
397
+ def reveal_credential(
398
+ self, profile_id: str, platform: str, vault_password: str
399
+ ) -> RevealedCredential:
400
+ return self._portal.run(
401
+ self._async.reveal_credential(profile_id, platform, vault_password)
402
+ )
403
+
404
+ def export_credentials(
405
+ self, profile_id: str, vault_password: str, password: str
406
+ ) -> CredentialBundle:
407
+ return self._portal.run(
408
+ self._async.export_credentials(profile_id, vault_password, password)
409
+ )
410
+
411
+ def import_credentials(
412
+ self, profile_id: str, vault_password: str, password: str, bundle: str
413
+ ) -> CredentialImportResult:
414
+ return self._portal.run(
415
+ self._async.import_credentials(profile_id, vault_password, password, bundle)
416
+ )
417
+
418
+ def vault_status(self) -> VaultStatus:
419
+ return self._portal.run(self._async.vault_status())
420
+
421
+ def set_vault_password(
422
+ self, new_password: str, current_password: Optional[str] = None
423
+ ) -> None:
424
+ return self._portal.run(
425
+ self._async.set_vault_password(new_password, current_password)
426
+ )
427
+
428
+ # ── sessions ──────────────────────────────────────────────────────────────
429
+
430
+ def export_session(
431
+ self, profile_id: str, password: str, *, kinds: Optional[Sequence[str]] = None
432
+ ) -> SessionExportResult:
433
+ return self._portal.run(self._async.export_session(profile_id, password, kinds=kinds))
434
+
435
+ def import_session(self, profile_id: str, password: str, bundle: str) -> Any:
436
+ return self._portal.run(self._async.import_session(profile_id, password, bundle))
437
+
438
+ # ── input / metrics ──────────────────────────────────────────────────────
439
+
440
+ def send_input(self, profile_id: str, body: Mapping[str, Any]) -> Any:
441
+ return self._portal.run(self._async.send_input(profile_id, body))
442
+
443
+ def get_metrics(self) -> Metrics:
444
+ return self._portal.run(self._async.get_metrics())
445
+
446
+ # ── agent runs ────────────────────────────────────────────────────────────
447
+
448
+ def list_runs(
449
+ self,
450
+ *,
451
+ profile_id: Optional[str] = None,
452
+ limit: Optional[int] = None,
453
+ offset: Optional[int] = None,
454
+ ) -> list[AgentRun]:
455
+ return self._portal.run(
456
+ self._async.list_runs(profile_id=profile_id, limit=limit, offset=offset)
457
+ )
458
+
459
+ def get_run(self, run_id: str) -> AgentRun:
460
+ return self._portal.run(self._async.get_run(run_id))
461
+
462
+ def list_run_steps(
463
+ self, run_id: str, *, limit: Optional[int] = None, offset: Optional[int] = None
464
+ ) -> list[RunStep]:
465
+ return self._portal.run(self._async.list_run_steps(run_id, limit=limit, offset=offset))
466
+
467
+ def get_run_shot(self, run_id: str, seq: int) -> ArtifactBytes:
468
+ return self._portal.run(self._async.get_run_shot(run_id, seq))
469
+
470
+ def get_activity(self) -> ActivitySnapshot:
471
+ return self._portal.run(self._async.get_activity())
472
+
473
+ # ── mailboxes ─────────────────────────────────────────────────────────────
474
+
475
+ def list_inboxes(self) -> list[Inbox]:
476
+ return self._portal.run(self._async.list_inboxes())
477
+
478
+ def create_inbox(self, body: Union[PutInboxBody, Mapping[str, Any]]) -> Inbox:
479
+ return self._portal.run(self._async.create_inbox(body))
480
+
481
+ def update_inbox(self, inbox_id: str, body: Union[PutInboxBody, Mapping[str, Any]]) -> Inbox:
482
+ return self._portal.run(self._async.update_inbox(inbox_id, body))
483
+
484
+ def delete_inbox(self, inbox_id: str) -> None:
485
+ self._portal.run(self._async.delete_inbox(inbox_id))
486
+
487
+ def get_inbox_bindings(self, profile_id: str) -> InboxBindings:
488
+ return self._portal.run(self._async.get_inbox_bindings(profile_id))
489
+
490
+ def bind_inbox(
491
+ self, profile_id: str, body: Union[BindInboxBody, Mapping[str, Any]]
492
+ ) -> InboxBindings:
493
+ return self._portal.run(self._async.bind_inbox(profile_id, body))
494
+
495
+ def unbind_inbox(self, profile_id: str, channel: str) -> None:
496
+ self._portal.run(self._async.unbind_inbox(profile_id, channel))
497
+
498
+ # ── passkeys ──────────────────────────────────────────────────────────────
499
+
500
+ def list_passkeys(self, profile_id: str) -> list[PasskeyRow]:
501
+ return self._portal.run(self._async.list_passkeys(profile_id))
502
+
503
+ def delete_passkey(self, profile_id: str, credential_id: str) -> None:
504
+ self._portal.run(self._async.delete_passkey(profile_id, credential_id))
505
+
506
+ # ── interruptions ─────────────────────────────────────────────────────────
507
+
508
+ def list_interruption_locks(self) -> InterruptionLockView:
509
+ return self._portal.run(self._async.list_interruption_locks())
510
+
511
+ def set_interruption_lock(
512
+ self, body: Union[SetInterruptionLockBody, Mapping[str, Any]]
513
+ ) -> None:
514
+ self._portal.run(self._async.set_interruption_lock(body))
515
+
516
+ def list_interruption_rules(self) -> list[InterruptionRuleRow]:
517
+ return self._portal.run(self._async.list_interruption_rules())
518
+
519
+ def set_interruption_rule(
520
+ self, body: Union[SetInterruptionRuleBody, Mapping[str, Any]]
521
+ ) -> None:
522
+ self._portal.run(self._async.set_interruption_rule(body))
523
+
524
+ def delete_interruption_rule(
525
+ self, origin: str, kind: str, profile_id: Optional[str] = None
526
+ ) -> None:
527
+ self._portal.run(self._async.delete_interruption_rule(origin, kind, profile_id))
528
+
529
+ # ── artifacts ─────────────────────────────────────────────────────────────
530
+
531
+ def put_artifact(
532
+ self, profile_id: str, data: bytes, name: Optional[str] = None
533
+ ) -> ArtifactPutResult:
534
+ return self._portal.run(self._async.put_artifact(profile_id, data, name))
535
+
536
+ def get_artifact(self, artifact_id: str) -> ArtifactBytes:
537
+ return self._portal.run(self._async.get_artifact(artifact_id))
538
+
539
+ # ── cookies ───────────────────────────────────────────────────────────────
540
+
541
+ def reveal_cookies(
542
+ self, profile_id: str, body: Union[RevealCookiesBody, Mapping[str, Any]]
543
+ ) -> RevealCookiesResult:
544
+ return self._portal.run(self._async.reveal_cookies(profile_id, body))
545
+
546
+ # ── account + health ──────────────────────────────────────────────────────
547
+
548
+ def get_account(self) -> Account:
549
+ return self._portal.run(self._async.get_account())
550
+
551
+ def health(self) -> HealthStatus:
552
+ return self._portal.run(self._async.health())
553
+
554
+ def ready(self) -> ReadyStatus:
555
+ return self._portal.run(self._async.ready())
556
+
557
+ # ── events ────────────────────────────────────────────────────────────────
558
+
559
+ def iter_events(self) -> Iterator[Any]:
560
+ """Yield lifecycle events synchronously (blocks the caller thread)."""
561
+ agen = self._async.events()
562
+ try:
563
+ while True:
564
+ yield self._portal.run(agen.__anext__())
565
+ except StopAsyncIteration:
566
+ return
567
+
568
+ # ── direct-CDP ────────────────────────────────────────────────────────────
569
+
570
+ def connect_cdp(
571
+ self,
572
+ target: Union[StartProfileResult, str],
573
+ profile_id: Optional[str] = None,
574
+ *,
575
+ attach: bool = True,
576
+ ) -> SyncCdpSession:
577
+ inner = self._portal.run(self._async.connect_cdp(target, profile_id, attach=attach))
578
+ return SyncCdpSession(inner, self._portal)
579
+
580
+ @contextmanager
581
+ def launch(self, profile_id: str, *, headless: bool = True) -> Iterator[SyncCdpSession]:
582
+ result = self.start_profile(profile_id, headless=headless)
583
+ cdp = self.connect_cdp(result, profile_id=profile_id)
584
+ try:
585
+ yield cdp
586
+ finally:
587
+ try:
588
+ cdp.close()
589
+ finally:
590
+ self.stop_profile(profile_id)
591
+
592
+ # ── lifecycle ─────────────────────────────────────────────────────────────
593
+
594
+ def close(self) -> None:
595
+ try:
596
+ self._portal.run(self._async.aclose())
597
+ finally:
598
+ self._portal.close()
599
+
600
+ def __enter__(self) -> "ScalebrowserClient":
601
+ return self
602
+
603
+ def __exit__(self, *exc: object) -> None:
604
+ self.close()
@@ -0,0 +1,3 @@
1
+ """Single source of the package version."""
2
+
3
+ __version__ = "0.2.0"