s-librarykit 0.5.2__tar.gz → 0.5.4__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.
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/PKG-INFO +1 -1
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/__init__.py +5 -0
- s_librarykit-0.5.4/librarykit/browser/_cdp_state.py +43 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/browser/capture_tap.py +10 -4
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/browser/nodriver.py +74 -0
- s_librarykit-0.5.4/librarykit/secret.py +66 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/sessions/__init__.py +26 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/pyproject.toml +1 -1
- s_librarykit-0.5.4/tests/test_cdp_state.py +58 -0
- s_librarykit-0.5.4/tests/test_ensure_kek.py +34 -0
- s_librarykit-0.5.4/tests/test_nodriver_state_context.py +127 -0
- s_librarykit-0.5.4/tests/test_secret.py +43 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/.gitignore +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/.gitlab-ci.yml +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/AGENTS.md +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/CHANGELOG.md +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/LICENSE +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/README.md +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/docs/superpowers/specs/2026-06-24-unified-sessions-design.md +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/antibot/__init__.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/antibot/_helpers.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/antibot/cdp.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/antibot/curl_cffi.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/antibot/page_evaluate.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/antibot/warmup_minter.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/auth.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/blocks.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/browser/__init__.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/browser/_engine.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/browser/camoufox.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/browser/geometry.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/browser/launchers.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/browser/persistent.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/browser/state.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/bulk.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/checkpoint.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/config_util.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/contract.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/cookie_refresh.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/cookies.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/entities.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/enums.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/errmap.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/errors.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/factory.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/forms.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/graphql.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/limit.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/orchestration/__init__.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/orchestration/health.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/orchestration/onboarding.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/orchestration/session_loader.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/pagination.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/pool.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/ports.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/protocols.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/redaction.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/refresh.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/retry.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/rpc.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/secret_store.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/session_bridge.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/session_paths.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/sessions/_const.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/sessions/_fsutil.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/stream.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/transport/__init__.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/transport/_helpers.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/transport/http_client.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/transport/permissive.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/transport/rest_client.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/transport/transport_http.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/upload.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/windows.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/conftest.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_antibot.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_auth.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_blocks.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_browser.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_bulk.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_capture_tap.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_checkpoint.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_config_util.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_cookie_refresh.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_cookies.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_errmap.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_errors.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_factory.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_forms.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_graphql.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_limit.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_limit_contract.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_nodriver_backend.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_orchestration.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_pagination.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_permissive_http.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_pool.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_redaction.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_refresh.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_retry.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_rpc.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_session_bridge.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_session_paths.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_sessions.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_stream.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_transport_client.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_transport_rest.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_transport_sync.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_upload.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_warmup_minter.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_windows.py +0 -0
- {s_librarykit-0.5.2 → s_librarykit-0.5.4}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: s-librarykit
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.4
|
|
4
4
|
Summary: Жирный КОРЕНЬ китов: единая error-иерархия, config/retry/paths-утили, checkpoint/resume, транспорт/auth/sessions — общий код для clikit, adapterkit и доменных пакетов.
|
|
5
5
|
Author: Dmitry
|
|
6
6
|
License: MIT
|
|
@@ -220,6 +220,7 @@ from librarykit.retry import (
|
|
|
220
220
|
# reference (`WebSocketsStreamTransport`) тянет extra [ws] lazy ВНУТРИ конструктора,
|
|
221
221
|
# поэтому реэкспорт безопасен — `import librarykit` без extra не падает.
|
|
222
222
|
from librarykit.rpc import JsonCodec, PrefixedJsonCodec, RpcClient
|
|
223
|
+
from librarykit.secret import Secret, mask_secrets_json
|
|
223
224
|
from librarykit.secret_store import SecretStore
|
|
224
225
|
|
|
225
226
|
# session_paths — единый резолвер путей браузерных сессий (~/.sessions): общая
|
|
@@ -236,6 +237,7 @@ from librarykit.sessions import (
|
|
|
236
237
|
KekUnavailableError,
|
|
237
238
|
SessionStore,
|
|
238
239
|
SessionStoreError,
|
|
240
|
+
ensure_kek,
|
|
239
241
|
resolve_kek,
|
|
240
242
|
)
|
|
241
243
|
from librarykit.stream import (
|
|
@@ -365,7 +367,10 @@ __all__ = [
|
|
|
365
367
|
"SessionStoreError",
|
|
366
368
|
"KekUnavailableError",
|
|
367
369
|
"resolve_kek",
|
|
370
|
+
"ensure_kek",
|
|
368
371
|
"SecretStore",
|
|
372
|
+
"Secret",
|
|
373
|
+
"mask_secrets_json",
|
|
369
374
|
# errors + errmap: единая иерархия ERROR-HUB + карта ответ→ошибка
|
|
370
375
|
"CliError",
|
|
371
376
|
"ApiError",
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Общие хелперы снятия storage_state для CDP-семьи (nodriver-backend + capture-тап).
|
|
2
|
+
|
|
3
|
+
`NodriverBackend.state_context()` и `capture_tap._CdpAdapter.storage_state()` оба снимают
|
|
4
|
+
сессию из nodriver `Tab` по CDP — общая логика (нормализация sameSite + чтение localStorage
|
|
5
|
+
активной вкладки в Playwright-форму origins) живёт здесь, чтобы не расходиться.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json as _json
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
_SAME_SITE_MAP = {"strict": "Strict", "lax": "Lax", "none": "None", "no_restriction": "None"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def normalize_samesite(value: Any) -> str | None:
|
|
16
|
+
"""CDP/nodriver same_site → Playwright-форма ('Strict'|'Lax'|'None'). None — опустить поле."""
|
|
17
|
+
if value is None:
|
|
18
|
+
return None
|
|
19
|
+
return _SAME_SITE_MAP.get(str(getattr(value, "value", value)).strip().lower())
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def read_origins(tab: Any) -> list[dict[str, Any]]:
|
|
23
|
+
"""localStorage ТЕКУЩЕГО origin активной вкладки → origins-список Playwright storage_state.
|
|
24
|
+
|
|
25
|
+
nodriver видит только активную вкладку (нет мульти-origin снимка, как у Playwright
|
|
26
|
+
context.storage_state) — best-effort; about:blank / ошибки evaluate → пустой список.
|
|
27
|
+
"""
|
|
28
|
+
try:
|
|
29
|
+
origin = await tab.evaluate("location.origin")
|
|
30
|
+
raw = await tab.evaluate("JSON.stringify(Object.entries(window.localStorage))")
|
|
31
|
+
except Exception:
|
|
32
|
+
return []
|
|
33
|
+
if not origin or origin == "null" or not isinstance(raw, str):
|
|
34
|
+
return []
|
|
35
|
+
try:
|
|
36
|
+
entries = _json.loads(raw)
|
|
37
|
+
except (ValueError, TypeError):
|
|
38
|
+
return []
|
|
39
|
+
ls = [{"name": k, "value": v} for k, v in entries]
|
|
40
|
+
return [{"origin": origin, "localStorage": ls}] if ls else []
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
__all__ = ["normalize_samesite", "read_origins"]
|
|
@@ -525,20 +525,26 @@ class _CdpAdapter(_BaseAdapter):
|
|
|
525
525
|
self._spawn(self._emit(self._h.on_action, NormalizedAction(raw=payload)))
|
|
526
526
|
|
|
527
527
|
async def storage_state(self) -> dict[str, Any]:
|
|
528
|
+
from ._cdp_state import normalize_samesite, read_origins
|
|
529
|
+
|
|
528
530
|
try:
|
|
529
531
|
raw = await self._tab.send(self._cdp.storage.get_cookies())
|
|
530
532
|
except Exception:
|
|
531
533
|
return {"cookies": [], "origins": []}
|
|
532
534
|
cookies = []
|
|
533
535
|
for c in raw or []:
|
|
534
|
-
|
|
536
|
+
ck = {
|
|
535
537
|
"name": c.name, "value": c.value, "domain": getattr(c, "domain", ""),
|
|
536
538
|
"path": getattr(c, "path", "/"), "expires": float(getattr(c, "expires", -1) or -1),
|
|
537
539
|
"httpOnly": bool(getattr(c, "http_only", False)),
|
|
538
540
|
"secure": bool(getattr(c, "secure", False)),
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
541
|
+
}
|
|
542
|
+
same = normalize_samesite(getattr(c, "same_site", None))
|
|
543
|
+
if same is not None:
|
|
544
|
+
ck["sameSite"] = same
|
|
545
|
+
cookies.append(ck)
|
|
546
|
+
# origins с localStorage текущей вкладки (паритет с _NodriverStateContext).
|
|
547
|
+
return {"cookies": cookies, "origins": await read_origins(self._tab)}
|
|
542
548
|
|
|
543
549
|
async def detach(self) -> None:
|
|
544
550
|
await self._drain()
|
|
@@ -126,13 +126,31 @@ class NodriverBackend:
|
|
|
126
126
|
)
|
|
127
127
|
out: list[dict[str, Any]] = []
|
|
128
128
|
for c in raw or []:
|
|
129
|
+
same = getattr(c, "same_site", getattr(c, "sameSite", None))
|
|
129
130
|
out.append({
|
|
130
131
|
"name": c.name, "value": c.value, "domain": getattr(c, "domain", ""),
|
|
131
132
|
"path": getattr(c, "path", "/"), "expires": getattr(c, "expires", -1),
|
|
132
133
|
"secure": getattr(c, "secure", False),
|
|
134
|
+
# http_only/same_site — для паритета storage_state (см. state_context).
|
|
135
|
+
"http_only": getattr(c, "http_only", getattr(c, "httpOnly", False)),
|
|
136
|
+
"same_site": str(getattr(same, "value", same)) if same is not None else None,
|
|
133
137
|
})
|
|
134
138
|
return out
|
|
135
139
|
|
|
140
|
+
def state_context(self) -> "_NodriverStateContext":
|
|
141
|
+
"""Playwright-`BrowserContext`-совместимый шов над этим backend'ом.
|
|
142
|
+
|
|
143
|
+
Даёт `.cookies()` (в Playwright-форме) и `.storage_state(path=)`, поэтому
|
|
144
|
+
generic-каскад `state.py` (`save_storage_state` / `wait_required_cookies` /
|
|
145
|
+
`warm_or_autologin`) работает на nodriver ТАК ЖЕ, как на Playwright-семье —
|
|
146
|
+
паритет извлечения сессии (см. `feedback-nodriver-default-engine`):
|
|
147
|
+
|
|
148
|
+
from librarykit.browser import save_storage_state
|
|
149
|
+
await save_storage_state(backend.state_context(), path,
|
|
150
|
+
required_cookies={"__Secure-1PSID"})
|
|
151
|
+
"""
|
|
152
|
+
return _NodriverStateContext(self)
|
|
153
|
+
|
|
136
154
|
async def fetch(
|
|
137
155
|
self,
|
|
138
156
|
method: str,
|
|
@@ -176,3 +194,59 @@ class NodriverBackend:
|
|
|
176
194
|
|
|
177
195
|
async def __aexit__(self, *exc: object) -> None:
|
|
178
196
|
await self.aclose()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# --------------------------------------------------------------------------- #
|
|
200
|
+
# Паритет извлечения сессии: Playwright-context-совместимый шов над nodriver #
|
|
201
|
+
# --------------------------------------------------------------------------- #
|
|
202
|
+
# Общая логика (sameSite + localStorage-origins) — в `_cdp_state`, чтобы не
|
|
203
|
+
# расходиться с capture-тапом `_CdpAdapter`. Имя `_normalize_samesite` сохранено
|
|
204
|
+
# как алиас (обратная совместимость публичного теста).
|
|
205
|
+
from ._cdp_state import normalize_samesite as _normalize_samesite # noqa: E402
|
|
206
|
+
from ._cdp_state import read_origins as _read_origins # noqa: E402
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _pw_cookie(c: Mapping[str, Any]) -> dict[str, Any]:
|
|
210
|
+
"""nodriver-cookie (из `NodriverBackend.cookies()`) → Playwright storage_state-форма."""
|
|
211
|
+
exp = c.get("expires")
|
|
212
|
+
ck: dict[str, Any] = {
|
|
213
|
+
"name": c["name"], "value": c["value"],
|
|
214
|
+
"domain": c.get("domain") or "", "path": c.get("path") or "/",
|
|
215
|
+
"expires": float(exp) if exp is not None else -1.0,
|
|
216
|
+
"httpOnly": bool(c.get("http_only", False)),
|
|
217
|
+
"secure": bool(c.get("secure", False)),
|
|
218
|
+
}
|
|
219
|
+
same = _normalize_samesite(c.get("same_site"))
|
|
220
|
+
if same is not None:
|
|
221
|
+
ck["sameSite"] = same
|
|
222
|
+
return ck
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class _NodriverStateContext:
|
|
226
|
+
"""Мини-адаптер: presentует nodriver-сессию как Playwright `BrowserContext`.
|
|
227
|
+
|
|
228
|
+
Реализует ровно то, на что duck-typed `state.py`: `cookies()` (Playwright-форма) и
|
|
229
|
+
`storage_state(path=)` (`{cookies, origins}` c localStorage текущего origin). Ничего
|
|
230
|
+
больше — это шов совместимости, не полный BrowserContext.
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
def __init__(self, backend: NodriverBackend) -> None:
|
|
234
|
+
self._b = backend
|
|
235
|
+
|
|
236
|
+
async def cookies(self) -> list[dict[str, Any]]:
|
|
237
|
+
return [_pw_cookie(c) for c in await self._b.cookies()]
|
|
238
|
+
|
|
239
|
+
async def storage_state(self, path: str | Path | None = None) -> dict[str, Any]:
|
|
240
|
+
"""Собрать `{cookies, origins}`; при заданном `path` — атомарно записать JSON."""
|
|
241
|
+
state = {"cookies": await self.cookies(), "origins": await self._origins()}
|
|
242
|
+
if path is not None:
|
|
243
|
+
target = Path(path)
|
|
244
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
245
|
+
tmp = target.with_suffix(target.suffix + ".tmp")
|
|
246
|
+
tmp.write_text(_json.dumps(state), encoding="utf-8")
|
|
247
|
+
tmp.replace(target)
|
|
248
|
+
return state
|
|
249
|
+
|
|
250
|
+
async def _origins(self) -> list[dict[str, Any]]:
|
|
251
|
+
"""localStorage ТЕКУЩЕГО origin (nodriver видит только активную вкладку). Best-effort."""
|
|
252
|
+
return await _read_origins(await self._b._ensure()) # noqa: SLF001 — свой же backend
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Secret — неутекаемый секрет: маска в repr/str/json, сырое только через .reveal().
|
|
2
|
+
|
|
3
|
+
НЕ subclass str: обёртка форсит явный .reveal() в точке использования (сборка httpx-
|
|
4
|
+
заголовка), поэтому секрет нельзя случайно склеить в URL/f-string/лог. Стандарт для
|
|
5
|
+
всех секрет-полей (Account.key, credential-токены, …) — новое поле оборачивается в
|
|
6
|
+
Secret и получает маскирование даром.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hmac
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
_MASK = "Secret(***)"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Secret:
|
|
17
|
+
__slots__ = ("_value",)
|
|
18
|
+
|
|
19
|
+
def __init__(self, value: str | "Secret") -> None:
|
|
20
|
+
self._value = value._value if isinstance(value, Secret) else value
|
|
21
|
+
|
|
22
|
+
def reveal(self) -> str:
|
|
23
|
+
"""Сырое значение — только в точке использования (заголовок/аутентификация)."""
|
|
24
|
+
return self._value
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def wrap(cls, value: "str | Secret | None") -> "Secret | None":
|
|
28
|
+
"""Идемпотентно обернуть строку в Secret (None → None, Secret → как есть)."""
|
|
29
|
+
if value is None:
|
|
30
|
+
return None
|
|
31
|
+
return value if isinstance(value, cls) else cls(value)
|
|
32
|
+
|
|
33
|
+
def __repr__(self) -> str:
|
|
34
|
+
return _MASK
|
|
35
|
+
|
|
36
|
+
def __str__(self) -> str:
|
|
37
|
+
return _MASK
|
|
38
|
+
|
|
39
|
+
def __format__(self, spec: str) -> str:
|
|
40
|
+
return _MASK
|
|
41
|
+
|
|
42
|
+
def __bool__(self) -> bool:
|
|
43
|
+
return bool(self._value)
|
|
44
|
+
|
|
45
|
+
def __len__(self) -> int:
|
|
46
|
+
return len(self._value)
|
|
47
|
+
|
|
48
|
+
def __eq__(self, other: object) -> bool:
|
|
49
|
+
if isinstance(other, Secret):
|
|
50
|
+
return hmac.compare_digest(self._value, other._value)
|
|
51
|
+
if isinstance(other, str):
|
|
52
|
+
return hmac.compare_digest(self._value, other)
|
|
53
|
+
return NotImplemented
|
|
54
|
+
|
|
55
|
+
def __hash__(self) -> int:
|
|
56
|
+
return hash(self._value) # для frozen-dataclass; hash необратим — не утечка
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def mask_secrets_json(obj: Any) -> Any:
|
|
60
|
+
"""`default=` для json.dumps: Secret → маска. Прочее — TypeError (стандартно)."""
|
|
61
|
+
if isinstance(obj, Secret):
|
|
62
|
+
return "***"
|
|
63
|
+
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
__all__ = ["Secret", "mask_secrets_json"]
|
|
@@ -237,6 +237,32 @@ def resolve_kek(*, allow_prompt: bool = True) -> bytes | None:
|
|
|
237
237
|
return None
|
|
238
238
|
|
|
239
239
|
|
|
240
|
+
def ensure_kek() -> bytes:
|
|
241
|
+
"""Гарантировать KEK: вернуть существующий или создать+записать в OS keyring.
|
|
242
|
+
|
|
243
|
+
Если KEK уже резолвится (keyring/env) — вернуть его. Иначе сгенерировать 32-байтный
|
|
244
|
+
ключ (Fernet.generate_key → urlsafe base64), записать в keyring ``librarykit/dek`` и
|
|
245
|
+
вернуть. Keyring недоступен → ``SessionStoreError`` (никакого молчаливого plaintext).
|
|
246
|
+
"""
|
|
247
|
+
existing = resolve_kek(allow_prompt=False)
|
|
248
|
+
if existing is not None:
|
|
249
|
+
return existing
|
|
250
|
+
try:
|
|
251
|
+
import keyring
|
|
252
|
+
except Exception as exc: # noqa: BLE001
|
|
253
|
+
raise SessionStoreError(
|
|
254
|
+
"keyring недоступен: невозможно создать KEK для шифрования секретов. "
|
|
255
|
+
"Установите keyring или задайте LIBRARYKIT_MASTER_KEY."
|
|
256
|
+
) from exc
|
|
257
|
+
if keyring is None: # тесты/окружение без бэкенда
|
|
258
|
+
raise SessionStoreError("keyring недоступен: невозможно создать KEK.")
|
|
259
|
+
from cryptography.fernet import Fernet
|
|
260
|
+
|
|
261
|
+
raw = Fernet.generate_key().decode("ascii") # urlsafe-base64 от 32 байт
|
|
262
|
+
keyring.set_password(KEYRING_SERVICE, KEYRING_KEK_USER, raw)
|
|
263
|
+
return _coerce_kek(raw)
|
|
264
|
+
|
|
265
|
+
|
|
240
266
|
# --------------------------------------------------------------------------- #
|
|
241
267
|
# Криптопровайдер: DEK обёрнут KEK; storage_state шифруется DEK-ом #
|
|
242
268
|
# --------------------------------------------------------------------------- #
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "s-librarykit"
|
|
3
|
-
version = "0.5.
|
|
3
|
+
version = "0.5.4"
|
|
4
4
|
description = "Жирный КОРЕНЬ китов: единая error-иерархия, config/retry/paths-утили, checkpoint/resume, транспорт/auth/sessions — общий код для clikit, adapterkit и доменных пакетов."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { text = "MIT" }
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Общие CDP-хелперы снятия storage_state (nodriver-backend + capture-тап)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from librarykit.browser._cdp_state import normalize_samesite, read_origins
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.mark.parametrize(
|
|
13
|
+
"raw,expected",
|
|
14
|
+
[("Strict", "Strict"), ("Lax", "Lax"), ("None", "None"),
|
|
15
|
+
("no_restriction", "None"), ("garbage", None), (None, None)],
|
|
16
|
+
)
|
|
17
|
+
def test_normalize_samesite(raw, expected):
|
|
18
|
+
assert normalize_samesite(raw) == expected
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_normalize_samesite_enum_like():
|
|
22
|
+
class _E:
|
|
23
|
+
value = "Strict"
|
|
24
|
+
assert normalize_samesite(_E()) == "Strict"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _FakeTab:
|
|
28
|
+
def __init__(self, origin="https://x.example", ls=None, raises=False):
|
|
29
|
+
self._origin = origin
|
|
30
|
+
self._ls = ls or {}
|
|
31
|
+
self._raises = raises
|
|
32
|
+
|
|
33
|
+
async def evaluate(self, js, **_):
|
|
34
|
+
if self._raises:
|
|
35
|
+
raise RuntimeError("evaluate недоступен")
|
|
36
|
+
if "location.origin" in js:
|
|
37
|
+
return self._origin
|
|
38
|
+
if "localStorage" in js:
|
|
39
|
+
return json.dumps(list(self._ls.items()))
|
|
40
|
+
raise AssertionError(js)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_read_origins_with_localstorage():
|
|
44
|
+
got = asyncio.run(read_origins(_FakeTab(origin="https://svc", ls={"a": "1", "b": "2"})))
|
|
45
|
+
assert got == [{"origin": "https://svc",
|
|
46
|
+
"localStorage": [{"name": "a", "value": "1"}, {"name": "b", "value": "2"}]}]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_read_origins_empty_localstorage():
|
|
50
|
+
assert asyncio.run(read_origins(_FakeTab(ls={}))) == []
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_read_origins_blank_origin():
|
|
54
|
+
assert asyncio.run(read_origins(_FakeTab(origin="null", ls={"a": "1"}))) == []
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_read_origins_evaluate_error_is_swallowed():
|
|
58
|
+
assert asyncio.run(read_origins(_FakeTab(raises=True))) == []
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
import librarykit.sessions as S
|
|
7
|
+
from librarykit import ensure_kek
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_returns_existing_kek(monkeypatch):
|
|
11
|
+
existing = base64.urlsafe_b64decode(base64.urlsafe_b64encode(b"0" * 32))
|
|
12
|
+
monkeypatch.setattr(S, "resolve_kek", lambda **kw: existing)
|
|
13
|
+
assert ensure_kek() == existing and len(ensure_kek()) == 32
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_creates_and_persists_when_absent(monkeypatch):
|
|
17
|
+
monkeypatch.setattr(S, "resolve_kek", lambda **kw: None)
|
|
18
|
+
saved = {}
|
|
19
|
+
|
|
20
|
+
class _KR:
|
|
21
|
+
def set_password(self, service, user, value):
|
|
22
|
+
saved[(service, user)] = value
|
|
23
|
+
|
|
24
|
+
monkeypatch.setitem(sys.modules, "keyring", _KR())
|
|
25
|
+
kek = ensure_kek()
|
|
26
|
+
assert len(kek) == 32
|
|
27
|
+
assert (S.KEYRING_SERVICE, S.KEYRING_KEK_USER) in saved # записан в keyring
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_raises_when_keyring_unavailable(monkeypatch):
|
|
31
|
+
monkeypatch.setattr(S, "resolve_kek", lambda **kw: None)
|
|
32
|
+
monkeypatch.setitem(sys.modules, "keyring", None)
|
|
33
|
+
with pytest.raises(S.SessionStoreError):
|
|
34
|
+
ensure_kek() # нет keyring → громкая ошибка, не plaintext
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Паритет извлечения сессии для nodriver: _NodriverStateContext + state.py-каскад.
|
|
2
|
+
|
|
3
|
+
Без реального nodriver (опциональный extra) — через duck-typed фейк-backend, т.к.
|
|
4
|
+
_NodriverStateContext зовёт лишь `backend.cookies()` + `backend._ensure()`.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from librarykit.browser.nodriver import (
|
|
14
|
+
_NodriverStateContext,
|
|
15
|
+
_normalize_samesite,
|
|
16
|
+
_pw_cookie,
|
|
17
|
+
)
|
|
18
|
+
from librarykit.browser.state import (
|
|
19
|
+
load_cookies_dict,
|
|
20
|
+
save_storage_state,
|
|
21
|
+
wait_required_cookies,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _FakeTab:
|
|
26
|
+
def __init__(self, origin="https://x.example", local_storage=None):
|
|
27
|
+
self._origin = origin
|
|
28
|
+
self._ls = local_storage or {}
|
|
29
|
+
|
|
30
|
+
async def evaluate(self, js, **_):
|
|
31
|
+
if "location.origin" in js:
|
|
32
|
+
return self._origin
|
|
33
|
+
if "localStorage" in js:
|
|
34
|
+
return json.dumps(list(self._ls.items()))
|
|
35
|
+
raise AssertionError(f"неожиданный evaluate: {js}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _FakeBackend:
|
|
39
|
+
"""Duck-typed под то, что реально дёргает _NodriverStateContext."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, cookies, tab=None):
|
|
42
|
+
self._cookies = cookies
|
|
43
|
+
self._tab = tab or _FakeTab()
|
|
44
|
+
|
|
45
|
+
async def cookies(self):
|
|
46
|
+
return list(self._cookies)
|
|
47
|
+
|
|
48
|
+
async def _ensure(self):
|
|
49
|
+
return self._tab
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _nd_cookie(name, value, **kw):
|
|
53
|
+
base = {
|
|
54
|
+
"name": name, "value": value, "domain": "x.example", "path": "/",
|
|
55
|
+
"expires": -1, "secure": True, "http_only": True, "same_site": "Lax",
|
|
56
|
+
}
|
|
57
|
+
base.update(kw)
|
|
58
|
+
return base
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# --- pure helpers --------------------------------------------------------- #
|
|
62
|
+
@pytest.mark.parametrize(
|
|
63
|
+
"raw,expected",
|
|
64
|
+
[("Strict", "Strict"), ("Lax", "Lax"), ("None", "None"),
|
|
65
|
+
("no_restriction", "None"), ("weird", None), (None, None)],
|
|
66
|
+
)
|
|
67
|
+
def test_normalize_samesite(raw, expected):
|
|
68
|
+
assert _normalize_samesite(raw) == expected
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_pw_cookie_maps_to_playwright_shape():
|
|
72
|
+
ck = _pw_cookie(_nd_cookie("sid", "v", expires=123.0))
|
|
73
|
+
assert ck["name"] == "sid" and ck["httpOnly"] is True and ck["secure"] is True
|
|
74
|
+
assert ck["sameSite"] == "Lax" and ck["expires"] == 123.0
|
|
75
|
+
assert "http_only" not in ck # переименовано, не продублировано
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_pw_cookie_omits_samesite_when_unknown():
|
|
79
|
+
ck = _pw_cookie(_nd_cookie("sid", "v", same_site=None))
|
|
80
|
+
assert "sameSite" not in ck
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# --- adapter -------------------------------------------------------------- #
|
|
84
|
+
def test_state_context_cookies_playwright_shape():
|
|
85
|
+
ctx = _NodriverStateContext(_FakeBackend([_nd_cookie("__Secure-1PSID", "x")]))
|
|
86
|
+
cookies = asyncio.run(ctx.cookies())
|
|
87
|
+
assert cookies[0]["name"] == "__Secure-1PSID" and cookies[0]["httpOnly"] is True
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_state_context_storage_state_dict_with_localstorage():
|
|
91
|
+
tab = _FakeTab(origin="https://gemini.google.com", local_storage={"k": "v"})
|
|
92
|
+
ctx = _NodriverStateContext(_FakeBackend([_nd_cookie("sid", "1")], tab=tab))
|
|
93
|
+
state = asyncio.run(ctx.storage_state())
|
|
94
|
+
assert state["cookies"][0]["name"] == "sid"
|
|
95
|
+
assert state["origins"] == [
|
|
96
|
+
{"origin": "https://gemini.google.com", "localStorage": [{"name": "k", "value": "v"}]}
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_state_context_origins_empty_on_blank():
|
|
101
|
+
tab = _FakeTab(origin="null", local_storage={})
|
|
102
|
+
ctx = _NodriverStateContext(_FakeBackend([], tab=tab))
|
|
103
|
+
assert asyncio.run(ctx.storage_state())["origins"] == []
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# --- интеграция со state.py-каскадом (тот же путь, что у Playwright-семьи) --- #
|
|
107
|
+
def test_save_storage_state_via_adapter(tmp_path):
|
|
108
|
+
ctx = _NodriverStateContext(_FakeBackend([_nd_cookie("__Secure-1PSID", "tok")]))
|
|
109
|
+
out = tmp_path / "sess" / "storage_state.json"
|
|
110
|
+
saved = asyncio.run(save_storage_state(ctx, out, required_cookies={"__Secure-1PSID"}))
|
|
111
|
+
assert saved.is_file()
|
|
112
|
+
assert load_cookies_dict(saved) == {"__Secure-1PSID": "tok"} # плоский для httpx
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_save_storage_state_raises_when_required_missing(tmp_path):
|
|
116
|
+
ctx = _NodriverStateContext(_FakeBackend([_nd_cookie("other", "1")]))
|
|
117
|
+
with pytest.raises(TimeoutError):
|
|
118
|
+
asyncio.run(save_storage_state(
|
|
119
|
+
ctx, tmp_path / "s.json",
|
|
120
|
+
required_cookies={"__Secure-1PSID"}, timeout_sec=0.05,
|
|
121
|
+
))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_wait_required_cookies_on_adapter():
|
|
125
|
+
ctx = _NodriverStateContext(_FakeBackend([_nd_cookie("a", "1"), _nd_cookie("b", "2")]))
|
|
126
|
+
assert asyncio.run(wait_required_cookies(ctx, {"a", "b"})) is True
|
|
127
|
+
assert asyncio.run(wait_required_cookies(ctx, {"a", "missing"}, timeout_sec=0.05)) is False
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from librarykit import Secret, mask_secrets_json
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_masks_in_repr_str_format():
|
|
9
|
+
s = Secret("sk-supersecret-123")
|
|
10
|
+
assert repr(s) == "Secret(***)"
|
|
11
|
+
assert str(s) == "Secret(***)"
|
|
12
|
+
assert f"{s}" == "Secret(***)"
|
|
13
|
+
assert "supersecret" not in repr(s) + str(s) + f"{s}"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_reveal_returns_raw():
|
|
17
|
+
assert Secret("tok-42").reveal() == "tok-42"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_not_str_subclass():
|
|
21
|
+
assert not isinstance(Secret("x"), str) # нельзя молча склеить в URL/f-string
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_bool_and_len_without_reveal():
|
|
25
|
+
assert bool(Secret("x")) is True and bool(Secret("")) is False
|
|
26
|
+
assert len(Secret("abcd")) == 4
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_eq_constant_time_and_wrap_idempotent():
|
|
30
|
+
assert Secret("a") == Secret("a")
|
|
31
|
+
assert Secret("a") == "a"
|
|
32
|
+
assert Secret("a") != Secret("b")
|
|
33
|
+
inner = Secret("z")
|
|
34
|
+
assert Secret(inner).reveal() == "z" # обёртка Secret(Secret) не двоится
|
|
35
|
+
assert Secret.wrap(None) is None
|
|
36
|
+
assert Secret.wrap("q").reveal() == "q"
|
|
37
|
+
assert Secret.wrap(inner) is inner # уже Secret → как есть
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_json_default_masks_and_bare_dumps_raises():
|
|
41
|
+
assert json.dumps({"k": Secret("v")}, default=mask_secrets_json) == '{"k": "***"}'
|
|
42
|
+
with pytest.raises(TypeError):
|
|
43
|
+
json.dumps({"k": Secret("v")}) # без энкодера — громкий отказ, не молча
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|