dawg-replay-kit 1.1.3__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,5 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ .pytest_cache/
5
+ dist/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dawgswarm
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,47 @@
1
+ Metadata-Version: 2.5
2
+ Name: dawg-replay-kit
3
+ Version: 1.1.3
4
+ Summary: Библиотека для скриптов dawg-replay: параметры, события, браузеры DAWG
5
+ Project-URL: Homepage, https://dawgswarm.ru
6
+ Project-URL: Source, https://github.com/dawgswarm/dawg-replay
7
+ Project-URL: Issues, https://github.com/dawgswarm/dawg-replay/issues
8
+ Author: dawgswarm
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Requires-Python: >=3.10
20
+ Requires-Dist: tomli>=2; python_version < '3.11'
21
+ Provides-Extra: browser
22
+ Requires-Dist: dawg-sdk-python<0.5,>=0.4; extra == 'browser'
23
+ Requires-Dist: httpx>=0.27; extra == 'browser'
24
+ Requires-Dist: playwright<2,>=1.58; extra == 'browser'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # dawg-replay-kit
28
+
29
+ Библиотека для скриптов приложения [dawg-replay](https://pypi.org/project/dawg-replay/): параметры запуска, события прогресса и результата, облачные браузеры DAWG, которые закрываются при выходе, ошибке и отмене.
30
+
31
+ Пакет нужен тем, кто пишет скрипты для dawg-replay. Обычно это кодинг-агент со скиллом dawg-replay-builder, руками ставить пакет не нужно: скрипт объявляет его в заголовке PEP 723, и `uv run` подтягивает его сам.
32
+
33
+ ```python
34
+ # /// script
35
+ # requires-python = ">=3.10"
36
+ # dependencies = ["dawg-replay-kit>=1.1,<2"]
37
+ # ///
38
+ import dawg_replay_kit as kit
39
+
40
+ kit.stage("Сбор")
41
+ kit.progress("pages", "Страницы", 1, 5)
42
+ kit.result("ok", "собрано 120 карточек")
43
+ ```
44
+
45
+ Мажорная версия kit совпадает с мажорной версией контракта скрипта. Контракт (манифест, типы параметров, события, окружение запуска): https://github.com/dawgswarm/dawg-replay/blob/main/docs/contract.md
46
+
47
+ Лицензия MIT.
@@ -0,0 +1,21 @@
1
+ # dawg-replay-kit
2
+
3
+ Библиотека для скриптов приложения [dawg-replay](https://pypi.org/project/dawg-replay/): параметры запуска, события прогресса и результата, облачные браузеры DAWG, которые закрываются при выходе, ошибке и отмене.
4
+
5
+ Пакет нужен тем, кто пишет скрипты для dawg-replay. Обычно это кодинг-агент со скиллом dawg-replay-builder, руками ставить пакет не нужно: скрипт объявляет его в заголовке PEP 723, и `uv run` подтягивает его сам.
6
+
7
+ ```python
8
+ # /// script
9
+ # requires-python = ">=3.10"
10
+ # dependencies = ["dawg-replay-kit>=1.1,<2"]
11
+ # ///
12
+ import dawg_replay_kit as kit
13
+
14
+ kit.stage("Сбор")
15
+ kit.progress("pages", "Страницы", 1, 5)
16
+ kit.result("ok", "собрано 120 карточек")
17
+ ```
18
+
19
+ Мажорная версия kit совпадает с мажорной версией контракта скрипта. Контракт (манифест, типы параметров, события, окружение запуска): https://github.com/dawgswarm/dawg-replay/blob/main/docs/contract.md
20
+
21
+ Лицензия MIT.
@@ -0,0 +1,43 @@
1
+ [project]
2
+ name = "dawg-replay-kit"
3
+ dynamic = ["version"]
4
+ description = "Библиотека для скриптов dawg-replay: параметры, события, браузеры DAWG"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.10"
9
+ authors = [{ name = "dawgswarm" }]
10
+ classifiers = [
11
+ "Operating System :: OS Independent",
12
+ "Programming Language :: Python :: 3",
13
+ "Programming Language :: Python :: 3 :: Only",
14
+ "Programming Language :: Python :: 3.10",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Programming Language :: Python :: 3.14",
19
+ ]
20
+ dependencies = ["tomli>=2; python_version < '3.11'"]
21
+
22
+ [project.urls]
23
+ Homepage = "https://dawgswarm.ru"
24
+ Source = "https://github.com/dawgswarm/dawg-replay"
25
+ Issues = "https://github.com/dawgswarm/dawg-replay/issues"
26
+
27
+ [project.optional-dependencies]
28
+ # Для kit.browser(). В скриптах эти пакеты и так перечислены в заголовке PEP 723.
29
+ browser = ["dawg-sdk-python>=0.4,<0.5", "playwright>=1.58,<2", "httpx>=0.27"]
30
+
31
+ [tool.hatch.version]
32
+ # Единственное место версии: __version__ в пакете. Мажор kit совпадает с мажором контракта.
33
+ path = "src/dawg_replay_kit/__init__.py"
34
+
35
+ [tool.uv]
36
+ # Версия динамическая (__version__ в __init__.py), а uv по умолчанию пересобирает пакет только
37
+ # при изменении pyproject.toml. Без этого ключа uv sync и uv run оставляют старую версию kit.
38
+ # Ключи заменяют набор uv по умолчанию, поэтому pyproject.toml указан явно.
39
+ cache-keys = [{ file = "pyproject.toml" }, { file = "src/dawg_replay_kit/__init__.py" }]
40
+
41
+ [build-system]
42
+ requires = ["hatchling>=1.27"]
43
+ build-backend = "hatchling.build"
@@ -0,0 +1,141 @@
1
+ """dawg-replay-kit: библиотека для скриптов dawg-replay.
2
+
3
+ import dawg_replay_kit as kit
4
+
5
+ kit.stage("Поиск")
6
+ for page in range(1, kit.params.max_pages + 1):
7
+ ...
8
+ kit.progress("pages", "Страницы", page, kit.params.max_pages)
9
+ kit.result("ok", "Собрано 120 карточек")
10
+
11
+ Окружение читается при первом обращении к kit: манифест из файла скрипта,
12
+ параметры из `DAWG_SCRIPT_PARAMS` или из аргументов командной строки.
13
+ Если параметры не подходят, скрипт завершается с кодом 2 и объяснением в stderr.
14
+
15
+ При импорте kit переводит stdout и stderr в UTF-8: на Windows вывод в файл
16
+ иначе идёт в кодировке локали, и кириллица в print ломает скрипт.
17
+
18
+ При инициализации kit ставит обработчик SIGTERM (на Windows ещё CTRL_BREAK)
19
+ и следит за файлом остановки из `DAWG_SCRIPT_STOP_FILE`: отмена запуска из
20
+ приложения превращается в SystemExit(143), поэтому `finally` и `atexit`
21
+ срабатывают, а отложенные события дописываются в файл.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import atexit
27
+ import sys
28
+ import threading
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ from dawg_replay_kit import cancel, stdio
33
+ from dawg_replay_kit.browser import BrowserLimitReached, BrowserSession
34
+ from dawg_replay_kit.dawg_api import BrowserError, BrowserLimitError
35
+ from dawg_replay_kit.events import Log
36
+ from dawg_replay_kit.manifest import SUPPORTED_CONTRACTS
37
+ from dawg_replay_kit.runtime import EXIT_BAD_PARAMS, Kit, StartupError
38
+
39
+ __version__ = "1.1.3" # мажор равен мажору контракта; версия задаётся только здесь
40
+
41
+ __all__ = [
42
+ "SUPPORTED_CONTRACTS",
43
+ "BrowserError",
44
+ "BrowserLimitError",
45
+ "BrowserLimitReached",
46
+ "BrowserSession",
47
+ "Kit",
48
+ "StartupError",
49
+ "__version__",
50
+ "artifact",
51
+ "browser",
52
+ "init",
53
+ "log",
54
+ "metric",
55
+ "progress",
56
+ "result",
57
+ "rows",
58
+ "set_browser_limit",
59
+ "stage",
60
+ ]
61
+
62
+ stdio.use_utf8()
63
+
64
+ _kit: Kit | None = None
65
+ _lock = threading.Lock()
66
+
67
+
68
+ def init(**kwargs: Any) -> Kit:
69
+ """Явная инициализация. Обычно не нужна: kit поднимается при первом обращении.
70
+
71
+ Аргументы те же, что у `Kit.from_environment`. Ошибку старта kit печатает
72
+ в stderr и завершает процесс с кодом 2.
73
+ """
74
+ global _kit
75
+ with _lock:
76
+ if _kit is None:
77
+ try:
78
+ _kit = Kit.from_environment(**kwargs)
79
+ except StartupError as e:
80
+ print(f"dawg-replay-kit: {e}", file=sys.stderr, flush=True)
81
+ raise SystemExit(EXIT_BAD_PARAMS) from None
82
+ atexit.register(_kit.close)
83
+ # Сигнал отмены и файл остановки превращаются в SystemExit: finally и atexit успевают отработать.
84
+ cancel.install()
85
+ return _kit
86
+
87
+
88
+ def _runtime() -> Kit:
89
+ return _kit if _kit is not None else init()
90
+
91
+
92
+ def __getattr__(name: str) -> Any:
93
+ if name in ("params", "output_dir", "run_id", "autonomous"):
94
+ return getattr(_runtime(), name)
95
+ raise AttributeError(f"module 'dawg_replay_kit' has no attribute {name!r}")
96
+
97
+
98
+ log = Log(lambda: _runtime().events)
99
+
100
+
101
+ def stage(name: str) -> None:
102
+ _runtime().events.stage(name)
103
+
104
+
105
+ def progress(id: str, label: str, done: int | float, total: int | float | None = None) -> None:
106
+ _runtime().events.progress(id, label, done, total)
107
+
108
+
109
+ def metric(key: str, label: str, value: int | float | str, unit: str | None = None) -> None:
110
+ _runtime().events.metric(key, label, value, unit)
111
+
112
+
113
+ def rows(table: str, rows: Any, columns: Any = None) -> None:
114
+ _runtime().events.rows(table, rows, columns)
115
+
116
+
117
+ def artifact(path: str | Path, label: str, mime: str | None = None) -> None:
118
+ _runtime().events.artifact(path, label, mime)
119
+
120
+
121
+ def result(status: str, summary: str) -> None:
122
+ _runtime().events.result(status, summary)
123
+
124
+
125
+ def browser(
126
+ proxy: str | None = None,
127
+ geo: str | tuple[float, float] | None = None,
128
+ ready_timeout: float = 120.0,
129
+ ) -> BrowserSession:
130
+ """Браузер DAWG для `with` или `async with`.
131
+
132
+ proxy: `socks5://user:pass@host:port` и подобное; geo: slug города (`moskva`)
133
+ или координаты `"55.75,37.61"`. Внутри доступны `.page`, `.context`, `.browser`,
134
+ `.session_id`.
135
+ """
136
+ return BrowserSession(_runtime().browsers, proxy, geo, ready_timeout)
137
+
138
+
139
+ def set_browser_limit(n: int) -> None:
140
+ """Сколько браузеров скрипт держит одновременно. По умолчанию 1."""
141
+ _runtime().browsers.set_limit(n)
@@ -0,0 +1,378 @@
1
+ """`kit.browser()`: облачный Chromium DAWG через Playwright по CDP (docs/contract.md, раздел 6).
2
+
3
+ with kit.browser(geo="moskva") as b:
4
+ b.page.goto("https://www.ozon.ru")
5
+
6
+ async with kit.browser() as b:
7
+ await b.page.goto("https://www.ozon.ru")
8
+
9
+ Порядок событий `session`:
10
+
11
+ - `provisioning` до запроса на создание;
12
+ - `open` с `session_id` сразу после ответа на POST, до ожидания готовности;
13
+ - `close` после освобождения, когда сессии больше нет в списке ключа;
14
+ - `failed`, если платформа ответила ошибкой и браузер точно не создан.
15
+
16
+ Браузер освобождается при выходе из `with`, при исключении, по сигналу отмены
17
+ (kit превращает его в SystemExit) и при выходе из процесса (atexit). Если процесс
18
+ убит жёстко, браузер освобождает приложение по событиям `open` без `close`.
19
+
20
+ Сколько браузеров одновременно держит скрипт, задаётся явно:
21
+ `kit.set_browser_limit(n)`, по умолчанию 1. Превышение даёт ошибку сразу,
22
+ без ожидания.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ import threading
29
+ from collections.abc import Callable
30
+ from dataclasses import dataclass
31
+ from typing import Any
32
+
33
+ from dawg_replay_kit.dawg_api import (
34
+ READY_TIMEOUT,
35
+ AsyncRestProvisioner,
36
+ BrowserError,
37
+ Created,
38
+ RestProvisioner,
39
+ Sessions,
40
+ build_payload,
41
+ )
42
+ from dawg_replay_kit.events import Emitter
43
+
44
+ DEFAULT_LIMIT = 1
45
+
46
+
47
+ class BrowserLimitReached(BrowserError):
48
+ """Скрипт пытается открыть больше браузеров, чем разрешил kit.set_browser_limit()."""
49
+
50
+
51
+ @dataclass
52
+ class Connection:
53
+ browser: Any
54
+ context: Any
55
+ page: Any
56
+ close: Callable[[], Any] # для async возвращает корутину
57
+
58
+
59
+ # --- подключение Playwright -------------------------------------------------
60
+
61
+ _local = threading.local()
62
+
63
+
64
+ def _sync_connect(ws_url: str) -> Connection:
65
+ """Подключение sync API. Один экземпляр Playwright на поток, пока открыт хоть один браузер."""
66
+ from playwright.sync_api import sync_playwright
67
+
68
+ if getattr(_local, "pw", None) is None:
69
+ _local.pw = sync_playwright().start()
70
+ _local.users = 0
71
+ _local.users += 1
72
+ try:
73
+ browser = _local.pw.chromium.connect_over_cdp(ws_url)
74
+ context = browser.contexts[0] if browser.contexts else browser.new_context()
75
+ page = context.pages[0] if context.pages else context.new_page()
76
+ except BaseException:
77
+ _sync_release_pw()
78
+ raise
79
+
80
+ def close() -> None:
81
+ try:
82
+ browser.close()
83
+ finally:
84
+ _sync_release_pw()
85
+
86
+ return Connection(browser, context, page, close)
87
+
88
+
89
+ def _sync_release_pw() -> None:
90
+ _local.users -= 1
91
+ if _local.users <= 0 and _local.pw is not None:
92
+ pw, _local.pw = _local.pw, None
93
+ try:
94
+ pw.stop()
95
+ except Exception:
96
+ pass
97
+
98
+
99
+ _async_pw: dict[int, list[Any]] = {} # id цикла -> [playwright, users]
100
+
101
+
102
+ async def _async_connect(ws_url: str) -> Connection:
103
+ from playwright.async_api import async_playwright
104
+
105
+ key = id(asyncio.get_running_loop())
106
+ if key not in _async_pw:
107
+ _async_pw[key] = [await async_playwright().start(), 0]
108
+ entry = _async_pw[key]
109
+ entry[1] += 1
110
+ try:
111
+ browser = await entry[0].chromium.connect_over_cdp(ws_url)
112
+ context = browser.contexts[0] if browser.contexts else await browser.new_context()
113
+ page = context.pages[0] if context.pages else await context.new_page()
114
+ except BaseException:
115
+ await _async_release_pw(key)
116
+ raise
117
+
118
+ async def close() -> None:
119
+ try:
120
+ await browser.close()
121
+ finally:
122
+ await _async_release_pw(key)
123
+
124
+ return Connection(browser, context, page, close)
125
+
126
+
127
+ async def _async_release_pw(key: int) -> None:
128
+ entry = _async_pw.get(key)
129
+ if entry is None:
130
+ return
131
+ entry[1] -= 1
132
+ if entry[1] <= 0:
133
+ _async_pw.pop(key, None)
134
+ try:
135
+ await entry[0].stop()
136
+ except Exception:
137
+ pass
138
+
139
+
140
+ # --- менеджер ---------------------------------------------------------------
141
+
142
+
143
+ class Browsers:
144
+ """Учёт браузеров скрипта: лимит, события, освобождение."""
145
+
146
+ def __init__(
147
+ self,
148
+ events: Emitter,
149
+ api_key: str | None,
150
+ base_url: str,
151
+ *,
152
+ provisioner: Callable[[], RestProvisioner] | None = None,
153
+ async_provisioner: Callable[[], AsyncRestProvisioner] | None = None,
154
+ sessions: Callable[[], Sessions] | None = None,
155
+ connect: Callable[[str], Connection] = _sync_connect,
156
+ aconnect: Callable[[str], Any] = _async_connect,
157
+ ):
158
+ self.events = events
159
+ self._api_key = api_key
160
+ self._base_url = base_url
161
+ self._provisioner = provisioner or (lambda: RestProvisioner(self._key(), self._base_url))
162
+ self._async_provisioner = async_provisioner or (lambda: AsyncRestProvisioner(self._key(), self._base_url))
163
+ self._sessions_factory = sessions or (lambda: Sessions(self._key(), self._base_url))
164
+ self._sessions: Sessions | None = None
165
+ self.connect = connect
166
+ self.aconnect = aconnect
167
+ self._limit = DEFAULT_LIMIT
168
+ self._in_use = 0
169
+ self._open: dict[str, str] = {} # session_id -> browser_id, ещё не освобождённые
170
+ self._lock = threading.Lock()
171
+
172
+ def _key(self) -> str:
173
+ if not self._api_key:
174
+ raise BrowserError("нет ключа DAWG: задайте переменную DAWG_API_KEY", created=False)
175
+ return self._api_key
176
+
177
+ @property
178
+ def limit(self) -> int:
179
+ return self._limit
180
+
181
+ def set_limit(self, n: int) -> None:
182
+ if isinstance(n, bool) or not isinstance(n, int) or n < 1:
183
+ raise ValueError("лимит браузеров должен быть целым не меньше 1")
184
+ self._limit = n
185
+
186
+ def open_sessions(self) -> dict[str, str]:
187
+ with self._lock:
188
+ return dict(self._open)
189
+
190
+ # --- слот лимита ---
191
+
192
+ def acquire(self) -> None:
193
+ with self._lock:
194
+ if self._in_use >= self._limit:
195
+ raise BrowserLimitReached(
196
+ f"скрипт уже держит {self._in_use} из {self._limit} разрешённых браузеров; "
197
+ "закройте один или поднимите лимит kit.set_browser_limit(n)",
198
+ created=False,
199
+ )
200
+ self._in_use += 1
201
+
202
+ def release_slot(self) -> None:
203
+ with self._lock:
204
+ self._in_use = max(0, self._in_use - 1)
205
+
206
+ # --- события ---
207
+
208
+ def on_retry(self, attempt: int, wait: float) -> None:
209
+ self.events.log("warn", f"платформа отказала в браузере (лимит тарифа), повтор {attempt} через {wait:.0f} с")
210
+
211
+ def provisioning(self) -> None:
212
+ self.events.session("provisioning")
213
+
214
+ def opened(self, created: Created) -> None:
215
+ with self._lock:
216
+ self._open[created.session_id] = created.browser_id
217
+ self.events.session("open", created.session_id, created.browser_id)
218
+
219
+ def failed(self, error: BrowserError) -> None:
220
+ if error.created is False:
221
+ self.events.session("failed")
222
+
223
+ # --- освобождение ---
224
+
225
+ def release(self, session_id: str) -> bool:
226
+ """Освобождает сессию. `close` пишется, только если её больше нет в списке ключа."""
227
+ with self._lock:
228
+ if session_id not in self._open:
229
+ return True
230
+ try:
231
+ if self._sessions is None:
232
+ self._sessions = self._sessions_factory()
233
+ gone = self._sessions.release(session_id)
234
+ except Exception as e:
235
+ self.events.log("error", f"браузер {session_id} не освобождён: {e}; его освободит приложение")
236
+ return False
237
+ if not gone:
238
+ self.events.log("error", f"браузер {session_id} всё ещё в списке сессий; его освободит приложение")
239
+ return False
240
+ with self._lock:
241
+ browser_id = self._open.pop(session_id, None)
242
+ self.events.session("close", session_id, browser_id)
243
+ return True
244
+
245
+ def release_all(self) -> None:
246
+ for sid in list(self.open_sessions()):
247
+ self.release(sid)
248
+
249
+ def close(self) -> None:
250
+ self.release_all()
251
+ if self._sessions is not None:
252
+ try:
253
+ self._sessions.close()
254
+ except Exception:
255
+ pass
256
+ self._sessions = None
257
+
258
+
259
+ class BrowserSession:
260
+ """Результат `kit.browser()`. Работает как `with` и как `async with`."""
261
+
262
+ def __init__(
263
+ self,
264
+ manager: Browsers,
265
+ proxy: str | None = None,
266
+ geo: str | tuple[float, float] | None = None,
267
+ ready_timeout: float = READY_TIMEOUT,
268
+ ):
269
+ self._m = manager
270
+ self._payload = build_payload(proxy, geo)
271
+ self._ready_timeout = ready_timeout
272
+ self.session_id: str | None = None
273
+ self.browser_id: str | None = None
274
+ self.browser: Any = None
275
+ self.context: Any = None
276
+ self.page: Any = None
277
+ self._conn: Connection | None = None
278
+ self._slot = False
279
+
280
+ def __repr__(self) -> str:
281
+ return f"<BrowserSession {self.session_id or 'not open'}>"
282
+
283
+ # --- sync ---
284
+
285
+ def __enter__(self) -> BrowserSession:
286
+ self._m.acquire()
287
+ self._slot = True
288
+ try:
289
+ prov = self._m._provisioner()
290
+ try:
291
+ self._m.provisioning()
292
+ try:
293
+ created = prov.create(self._payload, self._m.on_retry)
294
+ except BrowserError as e:
295
+ self._m.failed(e)
296
+ raise
297
+ self._opened(created)
298
+ ws_url = prov.wait_ready(created, self._ready_timeout)
299
+ finally:
300
+ prov.close()
301
+ self._attach(self._m.connect(ws_url))
302
+ except BaseException:
303
+ self._cleanup_sync()
304
+ raise
305
+ return self
306
+
307
+ def __exit__(self, *exc: object) -> None:
308
+ self._cleanup_sync()
309
+
310
+ def _cleanup_sync(self) -> None:
311
+ try:
312
+ if self._conn is not None:
313
+ conn, self._conn = self._conn, None
314
+ try:
315
+ conn.close()
316
+ except Exception:
317
+ pass
318
+ finally:
319
+ self._release_remote()
320
+
321
+ # --- async ---
322
+
323
+ async def __aenter__(self) -> BrowserSession:
324
+ self._m.acquire()
325
+ self._slot = True
326
+ try:
327
+ prov = self._m._async_provisioner()
328
+ try:
329
+ self._m.provisioning()
330
+ try:
331
+ created = await prov.create(self._payload, self._m.on_retry)
332
+ except BrowserError as e:
333
+ self._m.failed(e)
334
+ raise
335
+ self._opened(created)
336
+ ws_url = await prov.wait_ready(created, self._ready_timeout)
337
+ finally:
338
+ await prov.close()
339
+ self._attach(await self._m.aconnect(ws_url))
340
+ except BaseException:
341
+ await self._cleanup_async()
342
+ raise
343
+ return self
344
+
345
+ async def __aexit__(self, *exc: object) -> None:
346
+ await self._cleanup_async()
347
+
348
+ async def _cleanup_async(self) -> None:
349
+ try:
350
+ if self._conn is not None:
351
+ conn, self._conn = self._conn, None
352
+ try:
353
+ await conn.close()
354
+ except Exception:
355
+ pass
356
+ finally:
357
+ # Освобождение короткое (DELETE и список), идёт синхронно: так оно
358
+ # не прерывается отменой задачи на выходе из цикла.
359
+ self._release_remote()
360
+
361
+ # --- общее ---
362
+
363
+ def _opened(self, created: Created) -> None:
364
+ self.session_id, self.browser_id = created.session_id, created.browser_id
365
+ self._m.opened(created)
366
+
367
+ def _attach(self, conn: Connection) -> None:
368
+ self._conn = conn
369
+ self.browser, self.context, self.page = conn.browser, conn.context, conn.page
370
+
371
+ def _release_remote(self) -> None:
372
+ try:
373
+ if self.session_id is not None:
374
+ self._m.release(self.session_id)
375
+ finally:
376
+ if self._slot:
377
+ self._slot = False
378
+ self._m.release_slot()