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.
Files changed (112) hide show
  1. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/PKG-INFO +1 -1
  2. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/__init__.py +5 -0
  3. s_librarykit-0.5.4/librarykit/browser/_cdp_state.py +43 -0
  4. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/browser/capture_tap.py +10 -4
  5. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/browser/nodriver.py +74 -0
  6. s_librarykit-0.5.4/librarykit/secret.py +66 -0
  7. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/sessions/__init__.py +26 -0
  8. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/pyproject.toml +1 -1
  9. s_librarykit-0.5.4/tests/test_cdp_state.py +58 -0
  10. s_librarykit-0.5.4/tests/test_ensure_kek.py +34 -0
  11. s_librarykit-0.5.4/tests/test_nodriver_state_context.py +127 -0
  12. s_librarykit-0.5.4/tests/test_secret.py +43 -0
  13. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/.gitignore +0 -0
  14. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/.gitlab-ci.yml +0 -0
  15. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/AGENTS.md +0 -0
  16. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/CHANGELOG.md +0 -0
  17. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/LICENSE +0 -0
  18. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/README.md +0 -0
  19. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/docs/superpowers/specs/2026-06-24-unified-sessions-design.md +0 -0
  20. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/antibot/__init__.py +0 -0
  21. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/antibot/_helpers.py +0 -0
  22. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/antibot/cdp.py +0 -0
  23. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/antibot/curl_cffi.py +0 -0
  24. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/antibot/page_evaluate.py +0 -0
  25. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/antibot/warmup_minter.py +0 -0
  26. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/auth.py +0 -0
  27. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/blocks.py +0 -0
  28. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/browser/__init__.py +0 -0
  29. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/browser/_engine.py +0 -0
  30. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/browser/camoufox.py +0 -0
  31. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/browser/geometry.py +0 -0
  32. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/browser/launchers.py +0 -0
  33. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/browser/persistent.py +0 -0
  34. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/browser/state.py +0 -0
  35. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/bulk.py +0 -0
  36. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/checkpoint.py +0 -0
  37. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/config_util.py +0 -0
  38. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/contract.py +0 -0
  39. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/cookie_refresh.py +0 -0
  40. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/cookies.py +0 -0
  41. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/entities.py +0 -0
  42. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/enums.py +0 -0
  43. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/errmap.py +0 -0
  44. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/errors.py +0 -0
  45. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/factory.py +0 -0
  46. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/forms.py +0 -0
  47. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/graphql.py +0 -0
  48. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/limit.py +0 -0
  49. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/orchestration/__init__.py +0 -0
  50. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/orchestration/health.py +0 -0
  51. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/orchestration/onboarding.py +0 -0
  52. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/orchestration/session_loader.py +0 -0
  53. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/pagination.py +0 -0
  54. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/pool.py +0 -0
  55. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/ports.py +0 -0
  56. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/protocols.py +0 -0
  57. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/redaction.py +0 -0
  58. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/refresh.py +0 -0
  59. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/retry.py +0 -0
  60. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/rpc.py +0 -0
  61. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/secret_store.py +0 -0
  62. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/session_bridge.py +0 -0
  63. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/session_paths.py +0 -0
  64. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/sessions/_const.py +0 -0
  65. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/sessions/_fsutil.py +0 -0
  66. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/stream.py +0 -0
  67. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/transport/__init__.py +0 -0
  68. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/transport/_helpers.py +0 -0
  69. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/transport/http_client.py +0 -0
  70. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/transport/permissive.py +0 -0
  71. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/transport/rest_client.py +0 -0
  72. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/transport/transport_http.py +0 -0
  73. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/upload.py +0 -0
  74. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/librarykit/windows.py +0 -0
  75. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/conftest.py +0 -0
  76. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_antibot.py +0 -0
  77. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_auth.py +0 -0
  78. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_blocks.py +0 -0
  79. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_browser.py +0 -0
  80. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_bulk.py +0 -0
  81. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_capture_tap.py +0 -0
  82. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_checkpoint.py +0 -0
  83. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_config_util.py +0 -0
  84. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_cookie_refresh.py +0 -0
  85. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_cookies.py +0 -0
  86. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_errmap.py +0 -0
  87. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_errors.py +0 -0
  88. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_factory.py +0 -0
  89. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_forms.py +0 -0
  90. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_graphql.py +0 -0
  91. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_limit.py +0 -0
  92. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_limit_contract.py +0 -0
  93. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_nodriver_backend.py +0 -0
  94. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_orchestration.py +0 -0
  95. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_pagination.py +0 -0
  96. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_permissive_http.py +0 -0
  97. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_pool.py +0 -0
  98. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_redaction.py +0 -0
  99. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_refresh.py +0 -0
  100. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_retry.py +0 -0
  101. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_rpc.py +0 -0
  102. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_session_bridge.py +0 -0
  103. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_session_paths.py +0 -0
  104. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_sessions.py +0 -0
  105. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_stream.py +0 -0
  106. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_transport_client.py +0 -0
  107. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_transport_rest.py +0 -0
  108. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_transport_sync.py +0 -0
  109. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_upload.py +0 -0
  110. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_warmup_minter.py +0 -0
  111. {s_librarykit-0.5.2 → s_librarykit-0.5.4}/tests/test_windows.py +0 -0
  112. {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.2
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
- cookies.append({
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
- "sameSite": (getattr(getattr(c, "same_site", None), "value", None) or "Lax"),
540
- })
541
- return {"cookies": cookies, "origins": []}
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.2"
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