autotok 2.0.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.
autotok/__init__.py ADDED
@@ -0,0 +1,63 @@
1
+ """autotok: upload and schedule TikTok videos from Python or the command line.
2
+
3
+ Quick start::
4
+
5
+ import autotok
6
+
7
+ autotok.login("alice") # opens a browser once
8
+ autotok.upload_video("alice", "clip.mp4", "Hello #fyp")
9
+
10
+ client = autotok.Client.from_account("alice", proxy="http://user:pass@host:8000")
11
+ client.upload("clip.mp4", "Scheduled #fyp", schedule=3600)
12
+ """
13
+ from __future__ import annotations
14
+
15
+ __version__ = "2.0.0"
16
+
17
+ from .accounts import Account, AccountStore
18
+ from .errors import (
19
+ AccountNotFoundError,
20
+ AutotokError,
21
+ BrowserNotInstalledError,
22
+ LoginError,
23
+ MissingDependencyError,
24
+ NotLoggedInError,
25
+ PublishError,
26
+ PublishUncertainError,
27
+ SigningError,
28
+ UploadError,
29
+ ValidationError,
30
+ )
31
+ from .auth import import_session, login_interactive
32
+ from .env import load_env
33
+ from .proxy import Proxy, check_proxy, parse_proxy
34
+ from .uploader import Client, UploadResult, upload_video
35
+
36
+ login = login_interactive
37
+
38
+ __all__ = [
39
+ "__version__",
40
+ "Account",
41
+ "AccountStore",
42
+ "Client",
43
+ "Proxy",
44
+ "UploadResult",
45
+ "check_proxy",
46
+ "import_session",
47
+ "load_env",
48
+ "login",
49
+ "login_interactive",
50
+ "parse_proxy",
51
+ "upload_video",
52
+ "AutotokError",
53
+ "AccountNotFoundError",
54
+ "BrowserNotInstalledError",
55
+ "LoginError",
56
+ "MissingDependencyError",
57
+ "NotLoggedInError",
58
+ "PublishError",
59
+ "PublishUncertainError",
60
+ "SigningError",
61
+ "UploadError",
62
+ "ValidationError",
63
+ ]
autotok/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """``python -m autotok`` — same as the ``autotok`` command.
2
+
3
+ Useful on Windows when pip's Scripts folder isn't on PATH.
4
+ """
5
+ from .cli import run
6
+
7
+ run()
autotok/accounts.py ADDED
@@ -0,0 +1,233 @@
1
+ """Saved TikTok sessions.
2
+
3
+ Each account is one JSON file in ``$AUTOTOK_HOME/accounts/<name>.json`` holding
4
+ the TikTok cookies, the user agent of the browser that logged in, and the
5
+ account's proxy. Files are written with ``0600`` permissions: a ``sessionid``
6
+ cookie is full access to the TikTok account.
7
+
8
+ Sessions saved by TiktokAutoUploader 1.x (pickled ``CookiesDir/tiktok_session-
9
+ <name>.cookie`` files) are found automatically and migrated on first use. They
10
+ are read with a restricted unpickler that only accepts plain data, so a
11
+ tampered cookie file cannot execute code.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import logging
17
+ import os
18
+ import pickle
19
+ import re
20
+ import tempfile
21
+ from dataclasses import dataclass, field
22
+ from datetime import datetime, timezone
23
+ from pathlib import Path
24
+
25
+ from . import settings
26
+ from .errors import AccountNotFoundError, ValidationError
27
+ from .proxy import Proxy, parse_proxy
28
+
29
+ log = logging.getLogger("autotok")
30
+
31
+ SESSION_COOKIE = "sessionid"
32
+ DATACENTER_COOKIE = "tt-target-idc"
33
+ LEGACY_PREFIX = "tiktok_session-"
34
+ LEGACY_SUFFIX = ".cookie"
35
+
36
+ _NAME_RE = re.compile(r"^[A-Za-z0-9_.\-]{1,128}$")
37
+
38
+
39
+ def validate_account_name(name: str) -> str:
40
+ if not isinstance(name, str) or not _NAME_RE.match(name) or name in (".", ".."):
41
+ raise ValidationError(
42
+ f"invalid account name {name!r}: use 1-128 letters, digits, '.', '_' or '-'"
43
+ )
44
+ return name
45
+
46
+
47
+ def _now() -> str:
48
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
49
+
50
+
51
+ @dataclass
52
+ class Account:
53
+ name: str
54
+ cookies: list[dict] = field(default_factory=list)
55
+ user_agent: str | None = None
56
+ proxy: str | None = None
57
+ created_at: str = field(default_factory=_now)
58
+ updated_at: str = field(default_factory=_now)
59
+
60
+ def __post_init__(self) -> None:
61
+ validate_account_name(self.name)
62
+ if self.proxy is not None:
63
+ parsed = parse_proxy(self.proxy)
64
+ self.proxy = parsed.url if parsed else None
65
+
66
+ def cookie(self, name: str) -> str | None:
67
+ for c in self.cookies:
68
+ if c.get("name") == name and c.get("value"):
69
+ return str(c["value"])
70
+ return None
71
+
72
+ @property
73
+ def session_id(self) -> str | None:
74
+ return self.cookie(SESSION_COOKIE)
75
+
76
+ @property
77
+ def datacenter(self) -> str | None:
78
+ return self.cookie(DATACENTER_COOKIE)
79
+
80
+ @property
81
+ def has_session(self) -> bool:
82
+ return self.session_id is not None
83
+
84
+ def get_proxy(self) -> Proxy | None:
85
+ return parse_proxy(self.proxy)
86
+
87
+ @classmethod
88
+ def from_session_id(cls, name: str, session_id: str, *, datacenter: str | None = None,
89
+ user_agent: str | None = None, proxy: "str | Proxy | None" = None) -> "Account":
90
+ """Build an account from a ``sessionid`` copied out of a browser."""
91
+ if not session_id or not session_id.strip():
92
+ raise ValidationError("session id is empty")
93
+ cookies = [_cookie(SESSION_COOKIE, session_id.strip())]
94
+ if datacenter:
95
+ cookies.append(_cookie(DATACENTER_COOKIE, datacenter.strip()))
96
+ p = parse_proxy(proxy)
97
+ return cls(name=name, cookies=cookies, user_agent=user_agent, proxy=p.url if p else None)
98
+
99
+ def to_dict(self) -> dict:
100
+ return {
101
+ "name": self.name,
102
+ "cookies": self.cookies,
103
+ "user_agent": self.user_agent,
104
+ "proxy": self.proxy,
105
+ "created_at": self.created_at,
106
+ "updated_at": self.updated_at,
107
+ }
108
+
109
+ @classmethod
110
+ def from_dict(cls, data: dict) -> "Account":
111
+ return cls(
112
+ name=data["name"],
113
+ cookies=list(data.get("cookies") or []),
114
+ user_agent=data.get("user_agent"),
115
+ proxy=data.get("proxy"),
116
+ created_at=data.get("created_at") or _now(),
117
+ updated_at=data.get("updated_at") or _now(),
118
+ )
119
+
120
+
121
+ def _cookie(name: str, value: str) -> dict:
122
+ return {"name": name, "value": value, "domain": ".tiktok.com", "path": "/"}
123
+
124
+
125
+ class _PlainDataUnpickler(pickle.Unpickler):
126
+ """Only lists/dicts/str/numbers/bools/None: refuse every class lookup."""
127
+
128
+ def find_class(self, module, name): # noqa: D401
129
+ raise pickle.UnpicklingError(f"refusing to load {module}.{name} from a cookie file")
130
+
131
+
132
+ def read_legacy_cookie_file(path: Path) -> list[dict]:
133
+ with open(path, "rb") as f:
134
+ data = _PlainDataUnpickler(f).load()
135
+ if not isinstance(data, list) or not all(isinstance(c, dict) for c in data):
136
+ raise ValueError(f"{path} is not a list of cookies")
137
+ return data
138
+
139
+
140
+ class AccountStore:
141
+ def __init__(self, root: "str | Path | None" = None, legacy_dirs: "list[Path] | None" = None):
142
+ self.root = Path(root) if root else settings.accounts_dir()
143
+ self.legacy_dirs = settings.legacy_cookie_dirs() if legacy_dirs is None else list(legacy_dirs)
144
+
145
+ def path_for(self, name: str) -> Path:
146
+ return self.root / f"{validate_account_name(name)}.json"
147
+
148
+ def _legacy_path(self, name: str) -> Path | None:
149
+ for d in self.legacy_dirs:
150
+ p = d / f"{LEGACY_PREFIX}{name}{LEGACY_SUFFIX}"
151
+ if p.is_file():
152
+ return p
153
+ return None
154
+
155
+ def exists(self, name: str) -> bool:
156
+ return self.path_for(name).is_file() or self._legacy_path(name) is not None
157
+
158
+ def load(self, name: str) -> Account:
159
+ path = self.path_for(name)
160
+ if path.is_file():
161
+ try:
162
+ with open(path, encoding="utf-8") as f:
163
+ return Account.from_dict(json.load(f))
164
+ except (ValueError, KeyError, TypeError) as exc:
165
+ raise ValidationError(
166
+ f"account file {path} is unreadable ({exc}); log in again with --force"
167
+ ) from None
168
+ legacy = self._legacy_path(name)
169
+ if legacy is None:
170
+ raise AccountNotFoundError(
171
+ f"no saved session for '{name}'. Run: autotok login -n {name}"
172
+ )
173
+ try:
174
+ cookies = read_legacy_cookie_file(legacy)
175
+ except (pickle.UnpicklingError, ValueError, EOFError) as exc:
176
+ raise ValidationError(f"cannot migrate legacy session {legacy}: {exc}") from None
177
+ account = Account(name=name, cookies=cookies)
178
+ self.save(account)
179
+ log.info("Migrated legacy session %s to %s", legacy, path)
180
+ return account
181
+
182
+ def save(self, account: Account) -> Path:
183
+ self.root.mkdir(parents=True, exist_ok=True)
184
+ path = self.path_for(account.name)
185
+ account.updated_at = _now()
186
+ payload = json.dumps(account.to_dict(), indent=2)
187
+ fd, tmp = tempfile.mkstemp(prefix=".tmp-", dir=self.root)
188
+ try:
189
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
190
+ f.write(payload)
191
+ os.chmod(tmp, 0o600)
192
+ os.replace(tmp, path)
193
+ except BaseException:
194
+ try:
195
+ os.unlink(tmp)
196
+ except OSError:
197
+ pass
198
+ raise
199
+ return path
200
+
201
+ def delete(self, name: str) -> bool:
202
+ """Delete the saved session (and its legacy file, if any)."""
203
+ removed = False
204
+ path = self.path_for(name)
205
+ if path.is_file():
206
+ path.unlink()
207
+ removed = True
208
+ legacy = self._legacy_path(name)
209
+ if legacy is not None:
210
+ legacy.unlink()
211
+ removed = True
212
+ return removed
213
+
214
+ def list(self) -> list[str]:
215
+ names: set[str] = set()
216
+ if self.root.is_dir():
217
+ names.update(p.stem for p in self.root.glob("*.json") if _NAME_RE.match(p.stem))
218
+ for d in self.legacy_dirs:
219
+ if d.is_dir():
220
+ for p in d.iterdir():
221
+ n = p.name
222
+ if n.startswith(LEGACY_PREFIX) and n.endswith(LEGACY_SUFFIX):
223
+ stem = n[len(LEGACY_PREFIX):-len(LEGACY_SUFFIX)]
224
+ if _NAME_RE.match(stem):
225
+ names.add(stem)
226
+ return sorted(names)
227
+
228
+ def set_proxy(self, name: str, proxy: "str | Proxy | None") -> Account:
229
+ account = self.load(name)
230
+ p = parse_proxy(proxy)
231
+ account.proxy = p.url if p else None
232
+ self.save(account)
233
+ return account
autotok/auth.py ADDED
@@ -0,0 +1,175 @@
1
+ """Logging in to TikTok and saving the session.
2
+
3
+ ``login_interactive`` opens a real (headed) Chromium window on TikTok's login
4
+ page, routed through the account's proxy, and waits until TikTok sets the
5
+ ``sessionid`` cookie. The cookies and the browser's user agent are saved, so
6
+ later uploads present the same browser identity that created the session.
7
+
8
+ With a cloud browser (``AUTOTOK_BROWSER=browserbase`` or ``steel``) the
9
+ browser runs remotely instead, and you log in through its live view link from
10
+ your own browser.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import time
16
+ from typing import Callable, Optional
17
+
18
+ from . import settings
19
+ from .accounts import DATACENTER_COOKIE, SESSION_COOKIE, Account, AccountStore, validate_account_name
20
+ from .browsers import open_browser, sync_playwright
21
+ from .errors import LoginError
22
+ from .proxy import Proxy, parse_proxy
23
+
24
+ log = logging.getLogger("autotok")
25
+
26
+ REQUIRED_COOKIES = (SESSION_COOKIE, DATACENTER_COOKIE)
27
+
28
+
29
+ def wait_for_session_cookies(
30
+ get_cookies: Callable[[], list[dict]],
31
+ *,
32
+ poll_interval: float = 1.0,
33
+ timeout: float | None = None,
34
+ should_stop: Optional[Callable[[], bool]] = None,
35
+ sleep: Callable[[float], None] = time.sleep,
36
+ ) -> list[dict]:
37
+ """Poll ``get_cookies`` until ``sessionid`` and ``tt-target-idc`` are set.
38
+
39
+ Returns every TikTok cookie at that moment. Raises :class:`LoginError` on
40
+ timeout, cancellation, or when the browser is closed.
41
+ """
42
+ start = time.monotonic()
43
+ while True:
44
+ if should_stop and should_stop():
45
+ raise LoginError("login cancelled")
46
+ try:
47
+ cookies = get_cookies()
48
+ except Exception as exc:
49
+ raise LoginError(f"the browser was closed before login finished ({exc})") from None
50
+ names = {c.get("name") for c in cookies if c.get("value")}
51
+ if all(n in names for n in REQUIRED_COOKIES):
52
+ return [c for c in cookies if "tiktok" in str(c.get("domain", ""))]
53
+ if timeout is not None and time.monotonic() - start > timeout:
54
+ raise LoginError(f"timed out after {int(timeout)}s waiting for TikTok login")
55
+ try:
56
+ sleep(poll_interval)
57
+ except Exception as exc: # e.g. the user closed the browser window
58
+ raise LoginError(f"the browser was closed before login finished ({exc})") from None
59
+
60
+
61
+ def _keep_login_tab_in_front(context, login_page) -> None:
62
+ """Cloud live views show only the login tab, so a popup (e.g. "Continue
63
+ with Google") would be invisible and steal focus from it. Close popups and
64
+ keep the login tab in front."""
65
+ closed = False
66
+ for p in list(context.pages):
67
+ if p is not login_page and not p.is_closed():
68
+ try:
69
+ p.close()
70
+ closed = True
71
+ except Exception: # pragma: no cover - best effort
72
+ pass
73
+ if closed:
74
+ log.warning("Closed a popup window: popups (e.g. 'Continue with Google') can't be shown in "
75
+ "the live view. Log in with the QR code, email/username or phone instead.")
76
+ try:
77
+ login_page.bring_to_front()
78
+ except Exception: # pragma: no cover
79
+ pass
80
+
81
+
82
+ def _announce_live_view(url: str) -> None:
83
+ log.warning("Open this link in your browser and log in to TikTok there:\n %s", url)
84
+
85
+
86
+ def open_login_session(
87
+ *,
88
+ proxy: "str | Proxy | None" = None,
89
+ timeout: float | None = 600,
90
+ should_stop: Optional[Callable[[], bool]] = None,
91
+ on_live_url: Optional[Callable[[str], None]] = None,
92
+ ) -> tuple[list[dict], str]:
93
+ """Open a browser for the user to log in. Returns ``(cookies, user_agent)``.
94
+
95
+ Shared by the CLI and the Docker web app's virtual browser. For cloud
96
+ browsers, ``on_live_url`` receives the link the user logs in through
97
+ (default: it is logged).
98
+ """
99
+ from playwright.sync_api import Error as PlaywrightError
100
+
101
+ p = parse_proxy(proxy)
102
+ with sync_playwright() as pw:
103
+ session_timeout = (timeout or 3600) + 300
104
+ handle = open_browser(pw, headless=False, proxy=p, args=["--start-maximized"], timeout=session_timeout)
105
+ try:
106
+ if handle.is_remote:
107
+ context = handle.context()
108
+ page = handle.page(context)
109
+ else:
110
+ context = handle.context(no_viewport=True)
111
+ page = context.new_page()
112
+ try:
113
+ page.goto(settings.TIKTOK_LOGIN_URL, wait_until="domcontentloaded", timeout=60_000)
114
+ user_agent = page.evaluate("() => navigator.userAgent")
115
+ except PlaywrightError as exc:
116
+ hint = " (check the proxy)" if p else ""
117
+ raise LoginError(f"could not open TikTok's login page{hint}: {exc.message}") from None
118
+ if handle.is_remote:
119
+ (on_live_url or _announce_live_view)(handle.live_view_url())
120
+
121
+ def get_cookies() -> list[dict]:
122
+ if page.is_closed():
123
+ raise RuntimeError("page closed")
124
+ if handle.is_remote:
125
+ _keep_login_tab_in_front(context, page)
126
+ return context.cookies()
127
+
128
+ cookies = wait_for_session_cookies(
129
+ get_cookies,
130
+ timeout=timeout,
131
+ should_stop=should_stop,
132
+ # Keep Playwright's event loop running while we wait.
133
+ sleep=lambda s: page.wait_for_timeout(s * 1000),
134
+ )
135
+ finally:
136
+ handle.close()
137
+ return cookies, user_agent
138
+
139
+
140
+ def login_interactive(
141
+ name: str,
142
+ *,
143
+ proxy: "str | Proxy | None" = None,
144
+ timeout: float | None = 600,
145
+ store: AccountStore | None = None,
146
+ on_live_url: Optional[Callable[[str], None]] = None,
147
+ ) -> Account:
148
+ """Log in through a browser window and save the session as ``name``.
149
+
150
+ With a cloud browser, ``on_live_url`` gets the link to log in through.
151
+ """
152
+ validate_account_name(name)
153
+ p = parse_proxy(proxy)
154
+ cookies, user_agent = open_login_session(proxy=p, timeout=timeout, on_live_url=on_live_url)
155
+ account = Account(name=name, cookies=cookies, user_agent=user_agent, proxy=p.url if p else None)
156
+ (store or AccountStore()).save(account)
157
+ log.info("Saved session for '%s'", name)
158
+ return account
159
+
160
+
161
+ def import_session(
162
+ name: str,
163
+ session_id: str,
164
+ *,
165
+ datacenter: str | None = None,
166
+ user_agent: str | None = None,
167
+ proxy: "str | Proxy | None" = None,
168
+ store: AccountStore | None = None,
169
+ ) -> Account:
170
+ """Save a ``sessionid`` copied from a browser's cookies (no browser needed)."""
171
+ account = Account.from_session_id(
172
+ name, session_id, datacenter=datacenter, user_agent=user_agent, proxy=proxy
173
+ )
174
+ (store or AccountStore()).save(account)
175
+ return account
@@ -0,0 +1,90 @@
1
+ """Where login and signing browsers run.
2
+
3
+ A small factory picks the :class:`BrowserProvider` named by ``AUTOTOK_BROWSER``:
4
+
5
+ * ``local``: Chromium on this machine (default)
6
+ * ``browserbase``: Browserbase cloud browsers (``BROWSERBASE_API_KEY``)
7
+ * ``steel``: Steel Cloud, or a self-hosted steel-browser server (``STEEL_BASE_URL``)
8
+
9
+ Add your own with :func:`register_provider`::
10
+
11
+ from autotok.browsers import CloudProvider, register_provider
12
+
13
+ class MyProvider(CloudProvider):
14
+ label = "My cloud"
15
+ def create_session(self, *, proxy=None, timeout=None): ...
16
+
17
+ register_provider("mycloud", MyProvider) # then AUTOTOK_BROWSER=mycloud
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from typing import Callable
22
+
23
+ from .. import settings
24
+ from ..errors import ConfigurationError, MissingDependencyError
25
+ from ..proxy import Proxy
26
+ from .base import BrowserHandle, BrowserProvider, CloudProvider, CloudSession
27
+ from .browserbase import BrowserbaseProvider
28
+ from .local import LocalProvider, install_browser, launch_chromium
29
+ from .steel import SteelProvider
30
+
31
+ _REGISTRY: dict[str, Callable[[], BrowserProvider]] = {
32
+ "local": LocalProvider,
33
+ "browserbase": BrowserbaseProvider,
34
+ "steel": SteelProvider,
35
+ }
36
+
37
+
38
+ def register_provider(name: str, factory: Callable[[], BrowserProvider]) -> None:
39
+ """Make ``factory`` available as ``AUTOTOK_BROWSER=<name>``."""
40
+ _REGISTRY[name.strip().lower()] = factory
41
+
42
+
43
+ def available_providers() -> list[str]:
44
+ return sorted(_REGISTRY)
45
+
46
+
47
+ def get_provider(name: str | None = None) -> BrowserProvider:
48
+ """The provider called ``name``, or the one ``AUTOTOK_BROWSER`` selects."""
49
+ key = (name or settings.browser_provider()).strip().lower()
50
+ try:
51
+ factory = _REGISTRY[key]
52
+ except KeyError:
53
+ raise ConfigurationError(
54
+ f"unknown browser provider {key!r}; use one of: {', '.join(available_providers())}"
55
+ ) from None
56
+ return factory()
57
+
58
+
59
+ def open_browser(pw, *, headless: bool = True, proxy: Proxy | None = None,
60
+ args: list[str] | None = None, offline: bool = False,
61
+ timeout: float | None = None, provider: str | None = None) -> BrowserHandle:
62
+ """Start a browser from the selected provider (see :meth:`BrowserProvider.open`)."""
63
+ return get_provider(provider).open(pw, headless=headless, proxy=proxy, args=args,
64
+ offline=offline, timeout=timeout)
65
+
66
+
67
+ def sync_playwright():
68
+ try:
69
+ from playwright.sync_api import sync_playwright as _sp
70
+ except ImportError as exc: # pragma: no cover - playwright is a core dependency
71
+ raise MissingDependencyError("playwright is not installed: pip install autotok") from exc
72
+ return _sp()
73
+
74
+
75
+ __all__ = [
76
+ "BrowserHandle",
77
+ "BrowserProvider",
78
+ "CloudProvider",
79
+ "CloudSession",
80
+ "BrowserbaseProvider",
81
+ "LocalProvider",
82
+ "SteelProvider",
83
+ "available_providers",
84
+ "get_provider",
85
+ "install_browser",
86
+ "launch_chromium",
87
+ "open_browser",
88
+ "register_provider",
89
+ "sync_playwright",
90
+ ]
@@ -0,0 +1,112 @@
1
+ """The browser provider interface.
2
+
3
+ A :class:`BrowserProvider` starts a browser and returns a :class:`BrowserHandle`.
4
+ :class:`CloudProvider` covers services that hand out a remote Chromium over
5
+ CDP (Browserbase, Steel): subclasses only implement :meth:`create_session`.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from abc import ABC, abstractmethod
10
+
11
+ from ..errors import RemoteBrowserError
12
+ from ..proxy import Proxy
13
+
14
+
15
+ class CloudSession(ABC):
16
+ """A remote browser session."""
17
+
18
+ id: str
19
+ connect_url: str # CDP endpoint for Playwright's connect_over_cdp
20
+
21
+ @abstractmethod
22
+ def live_view_url(self) -> str:
23
+ """Link that lets a person watch and control the browser."""
24
+
25
+ @abstractmethod
26
+ def release(self) -> None:
27
+ """End the session now (stops billing). Must not raise."""
28
+
29
+
30
+ class BrowserHandle:
31
+ """A running browser: local Chromium, or a cloud session."""
32
+
33
+ def __init__(self, browser, *, session: CloudSession | None = None, provider: str = "local"):
34
+ self.browser = browser
35
+ self.session = session
36
+ self.provider = provider
37
+
38
+ @property
39
+ def is_remote(self) -> bool:
40
+ return self.session is not None
41
+
42
+ def context(self, **options):
43
+ """A browser context to work in. Cloud sessions come with one
44
+ preconfigured context (fingerprint, proxy), so ``options`` only apply
45
+ to local browsers."""
46
+ if self.is_remote:
47
+ return self.browser.contexts[0] if self.browser.contexts else self.browser.new_context()
48
+ return self.browser.new_context(**options)
49
+
50
+ @staticmethod
51
+ def page(context):
52
+ """The context's first page (cloud sessions open with one), or a new one."""
53
+ return context.pages[0] if context.pages else context.new_page()
54
+
55
+ def live_view_url(self) -> str | None:
56
+ return self.session.live_view_url() if self.session is not None else None
57
+
58
+ def close(self) -> None:
59
+ try:
60
+ self.browser.close()
61
+ except Exception: # pragma: no cover - best effort cleanup
62
+ pass
63
+ if self.session is not None:
64
+ self.session.release()
65
+
66
+ def __enter__(self) -> "BrowserHandle":
67
+ return self
68
+
69
+ def __exit__(self, *exc) -> None:
70
+ self.close()
71
+
72
+
73
+ class BrowserProvider(ABC):
74
+ """Starts browsers for login and signing."""
75
+
76
+ #: Human-readable name used in messages.
77
+ label: str = "browser"
78
+
79
+ @abstractmethod
80
+ def open(self, pw, *, headless: bool = True, proxy: Proxy | None = None,
81
+ args: list[str] | None = None, offline: bool = False,
82
+ timeout: float | None = None) -> BrowserHandle:
83
+ """Start a browser.
84
+
85
+ ``proxy`` routes its traffic through the account's proxy. ``offline``
86
+ browsers only run page scripts (every request is fulfilled by a route
87
+ handler), so they need no proxy. ``timeout`` (seconds) bounds how long
88
+ a cloud session may live.
89
+ """
90
+
91
+
92
+ class CloudProvider(BrowserProvider):
93
+ """A provider that hands out remote Chromium sessions over CDP."""
94
+
95
+ @abstractmethod
96
+ def create_session(self, *, proxy: Proxy | None = None, timeout: float | None = None,
97
+ offline: bool = False) -> CloudSession:
98
+ """Start a remote session (its traffic goes through ``proxy`` if given).
99
+
100
+ ``offline`` sessions only run page scripts, so providers should skip
101
+ any managed proxy for them."""
102
+
103
+ def open(self, pw, *, headless: bool = True, proxy: Proxy | None = None,
104
+ args: list[str] | None = None, offline: bool = False,
105
+ timeout: float | None = None) -> BrowserHandle:
106
+ session = self.create_session(proxy=None if offline else proxy, timeout=timeout, offline=offline)
107
+ try:
108
+ browser = pw.chromium.connect_over_cdp(session.connect_url)
109
+ except Exception as exc:
110
+ session.release()
111
+ raise RemoteBrowserError(f"could not connect to the {self.label} session: {exc}") from None
112
+ return BrowserHandle(browser, session=session, provider=self.label)