backstop-ai 0.6.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.
Files changed (58) hide show
  1. backstop/__init__.py +43 -0
  2. backstop/__main__.py +4 -0
  3. backstop/_httpcompat.py +119 -0
  4. backstop/adapters/__init__.py +73 -0
  5. backstop/admission.py +114 -0
  6. backstop/agent_guard.py +54 -0
  7. backstop/aimd.py +61 -0
  8. backstop/audit.py +79 -0
  9. backstop/audit_sink.py +212 -0
  10. backstop/budget.py +79 -0
  11. backstop/cache.py +157 -0
  12. backstop/circuit.py +85 -0
  13. backstop/cli.py +546 -0
  14. backstop/config.py +313 -0
  15. backstop/cost.py +36 -0
  16. backstop/dashboard_app.py +381 -0
  17. backstop/dashboard_demo.py +386 -0
  18. backstop/dashboard_ui.py +1356 -0
  19. backstop/demo.py +347 -0
  20. backstop/exceptions.py +77 -0
  21. backstop/extract.py +248 -0
  22. backstop/forecast.py +62 -0
  23. backstop/gateway.py +117 -0
  24. backstop/harness.py +225 -0
  25. backstop/hierarchical.py +167 -0
  26. backstop/hooks.py +29 -0
  27. backstop/latency.py +78 -0
  28. backstop/ledger.py +112 -0
  29. backstop/limiter.py +38 -0
  30. backstop/metrics.py +154 -0
  31. backstop/notifications.py +188 -0
  32. backstop/otel.py +179 -0
  33. backstop/pricing.py +299 -0
  34. backstop/quotas.py +86 -0
  35. backstop/real_anthropic.py +170 -0
  36. backstop/real_openai.py +202 -0
  37. backstop/retry.py +17 -0
  38. backstop/rollout.py +106 -0
  39. backstop/secrets.py +66 -0
  40. backstop/state.py +73 -0
  41. backstop/state_backends.py +201 -0
  42. backstop/streaming.py +196 -0
  43. backstop/telemetry.py +833 -0
  44. backstop/transports.py +1092 -0
  45. backstop/verify.py +705 -0
  46. backstop/wrapper.py +314 -0
  47. backstop/wrapper.py.backup +245 -0
  48. backstop/wrapper.py.backup2 +253 -0
  49. backstop_ai-0.6.0.dist-info/METADATA +308 -0
  50. backstop_ai-0.6.0.dist-info/RECORD +58 -0
  51. backstop_ai-0.6.0.dist-info/WHEEL +4 -0
  52. backstop_ai-0.6.0.dist-info/entry_points.txt +3 -0
  53. backstop_ai-0.6.0.dist-info/licenses/LICENSE.txt +21 -0
  54. wedge/__init__.py +2 -0
  55. wedge/cli.py +87 -0
  56. wedge/diff_engine.py +126 -0
  57. wedge/report.py +80 -0
  58. wedge/runner.py +563 -0
backstop/__init__.py ADDED
@@ -0,0 +1,43 @@
1
+ from .config import BackstopConfig, Priority
2
+ from .cost import CostEstimate, estimate as cost_estimate
3
+
4
+ __version__ = "0.6.0"
5
+ from .exceptions import (
6
+ BackstopError,
7
+ BudgetExceededError,
8
+ CircuitBreakerOpenError,
9
+ LatencyBudgetExceededError,
10
+ UnsupportedClientError,
11
+ )
12
+ from .extract import count_tokens
13
+ from .hooks import AfterResponseHook, BeforeRequestHook
14
+ from .latency import BackstopMeta
15
+ from .ledger import BudgetLedger, ReservationTicket, TenantBudget, get_current_tenant, get_ledger, with_budget
16
+ from .wrapper import Backstop
17
+
18
+ __all__ = [
19
+ "AfterResponseHook",
20
+ "Backstop",
21
+ "BackstopConfig",
22
+ "BackstopMeta",
23
+ "BeforeRequestHook",
24
+ "BudgetExceededError",
25
+ "BudgetLedger",
26
+ "CircuitBreakerOpenError",
27
+ "CostEstimate",
28
+ "LatencyBudgetExceededError",
29
+ "Priority",
30
+ "ReservationTicket",
31
+ "TenantBudget",
32
+ "UnsupportedClientError",
33
+ "BackstopError",
34
+ "budgets",
35
+ "count_tokens",
36
+ "get_current_tenant",
37
+ "with_budget",
38
+ "cost",
39
+ ]
40
+
41
+ budgets = get_ledger()
42
+ cost = type("_CostModule", (), {"estimate": staticmethod(cost_estimate)})()
43
+
backstop/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Entry point for ``python -m backstop``."""
2
+ from .cli import main
3
+
4
+ raise SystemExit(main())
@@ -0,0 +1,119 @@
1
+ """Select the httpx-compatible module each wrapped SDK actually uses.
2
+
3
+ ``openai>=3`` and ``anthropic>=1`` moved to ``httpx2``; older SDK majors use
4
+ ``httpx``. The two modules expose the same transport API surface but their
5
+ objects are mutually incompatible — an SDK rejects an http client built from
6
+ the other module, so Backstop must build its transports from the module the
7
+ wrapped SDK itself uses.
8
+
9
+ The module is detected from the wrapped client's own internal HTTP client,
10
+ so the choice always matches the SDK in front of us. When ``httpx2`` is not
11
+ installed, only the classic ``httpx`` compatibility is available.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+ import httpx
18
+
19
+ try: # httpx2 is optional: it arrives as a dependency of modern openai/anthropic SDKs
20
+ import httpx2 # noqa: F401
21
+
22
+ _HAS_HTTPX2 = True
23
+ except ImportError: # pragma: no cover - exercised only on httpx-only installs
24
+ httpx2 = None # type: ignore[assignment]
25
+ _HAS_HTTPX2 = False
26
+
27
+
28
+ class _HttpCompat:
29
+ """Namespace exposing one httpx-compatible module's transport surface."""
30
+
31
+ __slots__ = (
32
+ "module",
33
+ "name",
34
+ "Client",
35
+ "AsyncClient",
36
+ "BaseTransport",
37
+ "AsyncBaseTransport",
38
+ "HTTPTransport",
39
+ "AsyncHTTPTransport",
40
+ "Request",
41
+ "Response",
42
+ "URL",
43
+ "Timeout",
44
+ "TimeoutException",
45
+ "TransportError",
46
+ "ConnectError",
47
+ "ReadTimeout",
48
+ "MockTransport",
49
+ )
50
+
51
+ def __init__(self, module: Any) -> None:
52
+ self.module = module
53
+ self.name = getattr(module, "__name__", "")
54
+ self.Client = module.Client
55
+ self.AsyncClient = module.AsyncClient
56
+ self.BaseTransport = module.BaseTransport
57
+ self.AsyncBaseTransport = module.AsyncBaseTransport
58
+ self.HTTPTransport = module.HTTPTransport
59
+ self.AsyncHTTPTransport = module.AsyncHTTPTransport
60
+ self.Request = module.Request
61
+ self.Response = module.Response
62
+ self.URL = module.URL
63
+ self.Timeout = module.Timeout
64
+ self.TimeoutException = module.TimeoutException
65
+ self.TransportError = module.TransportError
66
+ self.ConnectError = module.ConnectError
67
+ self.ReadTimeout = module.ReadTimeout
68
+ self.MockTransport = module.MockTransport
69
+
70
+
71
+ HTTPX = _HttpCompat(httpx)
72
+ HTTPX2 = _HttpCompat(httpx2) if _HAS_HTTPX2 else None
73
+
74
+ # Module-level exports default to the classic httpx family: ``httpx`` is an
75
+ # unconditional dependency while ``httpx2`` is optional, so this is the only
76
+ # default that cannot fail on an httpx-only install. Per-client selection goes
77
+ # through :func:`compat_for` instead.
78
+ Client = HTTPX.Client
79
+ AsyncClient = HTTPX.AsyncClient
80
+ BaseTransport = HTTPX.BaseTransport
81
+ AsyncBaseTransport = HTTPX.AsyncBaseTransport
82
+ HTTPTransport = HTTPX.HTTPTransport
83
+ AsyncHTTPTransport = HTTPX.AsyncHTTPTransport
84
+ Request = HTTPX.Request
85
+ Response = HTTPX.Response
86
+ URL = HTTPX.URL
87
+ Timeout = HTTPX.Timeout
88
+ TimeoutException = HTTPX.TimeoutException
89
+ TransportError = HTTPX.TransportError
90
+ ConnectError = HTTPX.ConnectError
91
+ ReadTimeout = HTTPX.ReadTimeout
92
+ MockTransport = getattr(httpx, "MockTransport")
93
+
94
+
95
+ def compat_for(base_client: Any) -> _HttpCompat:
96
+ """Return the compat module matching the SDK's own internal client.
97
+
98
+ ``base_client`` is the wrapped SDK client's ``_client`` attribute. Its
99
+ class ``__mro__`` names the module family (``httpx`` vs ``httpx2``) that
100
+ built it, which is the module the SDK will accept back.
101
+ """
102
+ for cls in type(base_client).__mro__:
103
+ root = getattr(cls, "__module__", "").partition(".")[0]
104
+ if root == "httpx2":
105
+ if HTTPX2 is None:
106
+ break
107
+ return HTTPX2
108
+ if root == "httpx":
109
+ return HTTPX
110
+ return HTTPX
111
+
112
+
113
+ def module_root(obj: Any) -> str:
114
+ """Return ``httpx`` or ``httpx2`` for an object, whichever built its type."""
115
+ for cls in type(obj).__mro__:
116
+ root = getattr(cls, "__module__", "").partition(".")[0]
117
+ if root in ("httpx", "httpx2"):
118
+ return root
119
+ return ""
@@ -0,0 +1,73 @@
1
+ """Framework adapters (Deep Research P1#6).
2
+
3
+ Backstop enforces in-process, but agents live inside frameworks (LangChain,
4
+ LlamaIndex, CrewAI, AutoGen). These thin, optional-import adapters bridge the
5
+ framework's callback system to Backstop's hooks/metrics/tenant scoping so
6
+ Backstop becomes *the* guardrail inside the framework instead of a wrapper around
7
+ the raw SDK. Framework imports are deferred to call time, so ``import
8
+ backstop.adapters`` is always safe without the framework installed.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from typing import Any
13
+
14
+
15
+ class BackstopAdapter:
16
+ """Framework-agnostic bridge. Subclass or call ``on_llm_start`` /
17
+ ``on_llm_end`` from a framework callback to feed Backstop's metrics and
18
+ audit log. No framework dependency."""
19
+
20
+ def __init__(self, config: Any, tenant_id: str | None = None) -> None:
21
+ self.config = config
22
+ self.tenant_id = tenant_id
23
+
24
+ def on_llm_start(self, model: str, estimated_tokens: int = 0) -> dict:
25
+ return {"model": model, "estimated_tokens": estimated_tokens, "tenant_id": self.tenant_id}
26
+
27
+ def on_llm_end(self, model: str, prompt_tokens: int = 0, completion_tokens: int = 0) -> dict:
28
+ return {
29
+ "model": model,
30
+ "prompt_tokens": prompt_tokens,
31
+ "completion_tokens": completion_tokens,
32
+ "tenant_id": self.tenant_id,
33
+ }
34
+
35
+
36
+ def get_langchain_handler(config: Any, tenant_id: str | None = None) -> Any:
37
+ from langchain_core.callbacks import BaseCallbackHandler
38
+
39
+ adapter = BackstopAdapter(config, tenant_id)
40
+
41
+ class BackstopCallbackHandler(BaseCallbackHandler):
42
+ def on_llm_start(self, serialized, prompts, **kwargs):
43
+ model = (serialized or {}).get("name", "") if isinstance(serialized, dict) else ""
44
+ est = sum(len(p) // 4 for p in prompts) if prompts else 0
45
+ adapter.on_llm_start(model, est)
46
+
47
+ def on_llm_end(self, response, **kwargs):
48
+ model = getattr(response, "llm_output", None) or {}
49
+ usage = (model.get("token_usage") if isinstance(model, dict) else None) or {}
50
+ adapter.on_llm_end(
51
+ model.get("model_name", "") if isinstance(model, dict) else "",
52
+ usage.get("prompt_tokens", 0),
53
+ usage.get("completion_tokens", 0),
54
+ )
55
+
56
+ return BackstopCallbackHandler()
57
+
58
+
59
+ def get_llama_index_callback(config: Any, tenant_id: str | None = None) -> Any:
60
+ from llama_index.core.callbacks import BaseCallbackHandler as LlamaBase
61
+
62
+ adapter = BackstopAdapter(config, tenant_id)
63
+
64
+ class BackstopLlamaHandler(LlamaBase):
65
+ def on_event_start(self, event_type, payload=None, event_id="", parent_id="", **kwargs):
66
+ if str(event_type).lower().startswith("llm"):
67
+ adapter.on_llm_start(str(payload.get("model", "")) if isinstance(payload, dict) else "")
68
+
69
+ def on_event_end(self, event_type, payload=None, event_id="", parent_id="", **kwargs):
70
+ if str(event_type).lower().startswith("llm"):
71
+ adapter.on_llm_end(str(payload.get("model", "")) if isinstance(payload, dict) else "")
72
+
73
+ return BackstopLlamaHandler()
backstop/admission.py ADDED
@@ -0,0 +1,114 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import itertools
5
+ import threading
6
+ import time
7
+ from collections import deque
8
+ from dataclasses import dataclass
9
+
10
+ from .aimd import AIMDController
11
+ from .config import BackstopConfig, Priority
12
+
13
+
14
+ @dataclass
15
+ class _Ticket:
16
+ priority: Priority
17
+ sequence: int
18
+ enqueued_at: float
19
+
20
+
21
+ class PriorityGate:
22
+ def __init__(self, config: BackstopConfig, aimd: AIMDController) -> None:
23
+ self._config = config
24
+ self._aimd = aimd
25
+ self._active = 0
26
+ self._counter = itertools.count()
27
+ self._queues: dict[Priority, deque[_Ticket]] = {
28
+ Priority.CRITICAL: deque(),
29
+ Priority.DEFAULT: deque(),
30
+ Priority.BACKGROUND: deque(),
31
+ }
32
+ self._condition = threading.Condition()
33
+ self._async_condition = asyncio.Condition()
34
+
35
+ @property
36
+ def active(self) -> int:
37
+ with self._condition:
38
+ return self._active
39
+
40
+ @property
41
+ def depth(self) -> int:
42
+ with self._condition:
43
+ return sum(len(queue) for queue in self._queues.values())
44
+
45
+ def acquire(self, priority: Priority, timeout: float | None = None) -> float:
46
+ ticket = _Ticket(priority, next(self._counter), time.monotonic())
47
+ effective = timeout if timeout is not None else self._config.queue_timeout
48
+ with self._condition:
49
+ self._queues[priority].append(ticket)
50
+ self._condition.notify_all()
51
+ while not self._can_admit(ticket):
52
+ if effective is None:
53
+ self._condition.wait()
54
+ else:
55
+ if not self._condition.wait(timeout=effective):
56
+ raise TimeoutError("gate acquire timed out")
57
+ self._queues[priority].popleft()
58
+ self._active += 1
59
+ return time.monotonic() - ticket.enqueued_at
60
+
61
+ def release(self) -> None:
62
+ with self._condition:
63
+ self._active = max(0, self._active - 1)
64
+ self._condition.notify_all()
65
+
66
+ async def aacquire(self, priority: Priority, timeout: float | None = None) -> float:
67
+ ticket = _Ticket(priority, next(self._counter), time.monotonic())
68
+ effective = timeout if timeout is not None else self._config.queue_timeout
69
+ async with self._async_condition:
70
+ self._queues[priority].append(ticket)
71
+ self._async_condition.notify_all()
72
+ while not self._can_admit(ticket):
73
+ try:
74
+ if effective is None:
75
+ await self._async_condition.wait()
76
+ else:
77
+ await asyncio.wait_for(self._async_condition.wait(), effective)
78
+ except asyncio.TimeoutError:
79
+ raise TimeoutError("gate acquire timed out")
80
+ self._queues[priority].popleft()
81
+ self._active += 1
82
+ return time.monotonic() - ticket.enqueued_at
83
+
84
+ async def arelease(self) -> None:
85
+ async with self._async_condition:
86
+ self._active = max(0, self._active - 1)
87
+ self._async_condition.notify_all()
88
+
89
+ def _can_admit(self, ticket: _Ticket) -> bool:
90
+ if self._active >= self._aimd.current_limit:
91
+ return False
92
+ chosen = self._choose_ticket()
93
+ return chosen is ticket
94
+
95
+ def _choose_ticket(self) -> _Ticket | None:
96
+ now = time.monotonic()
97
+ critical = self._queues[Priority.CRITICAL]
98
+ if critical:
99
+ return critical[0]
100
+
101
+ aged = [
102
+ queue[0]
103
+ for queue in self._queues.values()
104
+ if queue and now - queue[0].enqueued_at >= self._config.starvation_after_seconds
105
+ ]
106
+ if aged:
107
+ return min(aged, key=lambda item: item.sequence)
108
+
109
+ for priority in (Priority.DEFAULT, Priority.BACKGROUND):
110
+ queue = self._queues[priority]
111
+ if queue:
112
+ return queue[0]
113
+ return None
114
+
@@ -0,0 +1,54 @@
1
+ """Agent-native guardrails (Deep Research P2#11).
2
+
3
+ Coding/agent loops can run away: repeated tool calls, runaway generation, or
4
+ stalls. ``AgentGuard`` tracks per-agent request counts and token spend within a
5
+ sliding window and blocks (returns False) once a ceiling is hit, so a misbehaving
6
+ agent is fenced by Backstop rather than silently burning the whole budget.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import threading
11
+ import time
12
+ from collections import deque
13
+
14
+
15
+ class AgentGuard:
16
+ def __init__(
17
+ self,
18
+ max_calls: int = 100,
19
+ window_seconds: float = 60.0,
20
+ max_tokens: int | None = None,
21
+ ) -> None:
22
+ self.max_calls = max_calls
23
+ self.window_seconds = window_seconds
24
+ self.max_tokens = max_tokens
25
+ self._calls: dict[str, deque[float]] = {}
26
+ self._tokens: dict[str, deque[tuple[float, int]]] = {}
27
+ self._lock = threading.Lock()
28
+
29
+ def allow(self, agent_id: str, tokens: int = 0) -> bool:
30
+ now = time.monotonic()
31
+ with self._lock:
32
+ calls = self._calls.setdefault(agent_id, deque())
33
+ self._trim(calls, now)
34
+ if len(calls) >= self.max_calls:
35
+ return False
36
+ calls.append(now)
37
+
38
+ if self.max_tokens is not None and tokens > 0:
39
+ spent = self._tokens.setdefault(agent_id, deque())
40
+ self._trim_tokens(spent, now)
41
+ if sum(t for _, t in spent) + tokens > self.max_tokens:
42
+ return False
43
+ spent.append((now, tokens))
44
+ return True
45
+
46
+ def _trim(self, calls: deque[float], now: float) -> None:
47
+ cutoff = now - self.window_seconds
48
+ while calls and calls[0] < cutoff:
49
+ calls.popleft()
50
+
51
+ def _trim_tokens(self, spent: deque[tuple[float, int]], now: float) -> None:
52
+ cutoff = now - self.window_seconds
53
+ while spent and spent[0][0] < cutoff:
54
+ spent.popleft()
backstop/aimd.py ADDED
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ import time
5
+
6
+ from .config import BackstopConfig
7
+
8
+
9
+ class AIMDController:
10
+ def __init__(self, config: BackstopConfig) -> None:
11
+ self._config = config
12
+ self._limit = config.initial_concurrency
13
+ # Initialize so the first adjustment is always allowed regardless
14
+ # of how long this system has been running (monotonic clock).
15
+ self._last_adjustment = -self._config.aimd_adjustment_interval
16
+ self._lock = threading.Lock()
17
+
18
+ @property
19
+ def current_limit(self) -> int:
20
+ with self._lock:
21
+ return self._limit
22
+
23
+ def record_success(self) -> bool:
24
+ now = time.monotonic()
25
+ with self._lock:
26
+ if now - self._last_adjustment < self._config.aimd_adjustment_interval:
27
+ return False
28
+ if self._limit >= self._config.max_concurrency:
29
+ return False
30
+ self._limit = min(self._config.max_concurrency, self._limit + self._config.aimd_increase)
31
+ self._last_adjustment = now
32
+ return True
33
+
34
+ def record_pressure(self) -> bool:
35
+ now = time.monotonic()
36
+ with self._lock:
37
+ if now - self._last_adjustment < self._config.aimd_adjustment_interval:
38
+ return False
39
+ new_limit = max(
40
+ self._config.min_concurrency,
41
+ int(self._limit * self._config.aimd_decrease_factor),
42
+ )
43
+ if new_limit == self._limit:
44
+ return False
45
+ self._limit = new_limit
46
+ self._last_adjustment = now
47
+ return True
48
+
49
+ def apply_external_decrease(self, pressure: float = 1.0) -> bool:
50
+ """Proactively clamp the limit from an out-of-band signal (e.g. provider
51
+ quota pressure). Bypasses the adjustment-interval throttle so a quota
52
+ warning takes effect on the very next request."""
53
+ with self._lock:
54
+ factor = max(self._config.aimd_decrease_factor, min(1.0, pressure))
55
+ new_limit = max(self._config.min_concurrency, int(self._limit * factor))
56
+ if new_limit == self._limit:
57
+ return False
58
+ self._limit = new_limit
59
+ self._last_adjustment = time.monotonic()
60
+ return True
61
+
backstop/audit.py ADDED
@@ -0,0 +1,79 @@
1
+ """Tamper-evident audit log (Deep Research P2#8).
2
+
3
+ Every enforcement decision (deny / fallback / downgrade / cache_hit) is appended
4
+ as a JSON record whose integrity is chained: each record carries an HMAC of its
5
+ own payload plus the previous record's chain hash. `verify()` replays the chain
6
+ and detects any tampering. This is the enterprise "escape hatch" that makes
7
+ in-process enforcement audit-ready and closes the supply-chain / audit gap the
8
+ research flagged (e.g. the March-2026 LiteLLM incident class).
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import hmac
14
+ import json
15
+ import threading
16
+ import time
17
+ from typing import Any, Callable
18
+
19
+
20
+ def _chain_hash(prev: bytes, payload: bytes, key: bytes) -> str:
21
+ mac = hmac.new(key, prev + payload, hashlib.sha256).hexdigest()
22
+ return hashlib.sha256((prev.hex() + mac).encode()).hexdigest()
23
+
24
+
25
+ class AuditLog:
26
+ def __init__(self, sink: str | Callable[[str], None], hmac_key: str | None = None) -> None:
27
+ self._key = (hmac_key or "").encode("utf-8")
28
+ self._prev = b""
29
+ self._lock = threading.Lock()
30
+ self._file = open(sink, "a", encoding="utf-8") if isinstance(sink, str) else None
31
+ self._callable = sink if callable(sink) else None
32
+
33
+ def record(self, decision: str, reason: str, **fields: Any) -> dict:
34
+ rec = {"ts": time.time(), "decision": decision, "reason": reason}
35
+ if fields:
36
+ rec.update(fields)
37
+ payload = json.dumps(rec, sort_keys=True, default=str).encode("utf-8")
38
+ with self._lock:
39
+ chain = _chain_hash(self._prev, payload, self._key)
40
+ rec["_chain"] = chain
41
+ self._prev = bytes.fromhex(chain)
42
+ line = json.dumps(rec, sort_keys=True, default=str)
43
+ with self._lock:
44
+ if self._file is not None:
45
+ self._file.write(line + "\n")
46
+ self._file.flush()
47
+ elif self._callable is not None:
48
+ self._callable(line)
49
+ return rec
50
+
51
+ def verify(self, lines: list[str] | None = None) -> bool:
52
+ prev = b""
53
+ source = lines if lines is not None else self._read_lines()
54
+ for line in source:
55
+ line = line.strip()
56
+ if not line:
57
+ continue
58
+ rec = json.loads(line)
59
+ payload = json.dumps(
60
+ {k: v for k, v in rec.items() if k != "_chain"}, sort_keys=True, default=str
61
+ ).encode("utf-8")
62
+ chain = _chain_hash(prev, payload, self._key)
63
+ if chain != rec.get("_chain"):
64
+ return False
65
+ prev = bytes.fromhex(rec["_chain"])
66
+ return True
67
+
68
+ def _read_lines(self) -> list[str]:
69
+ if self._file is None:
70
+ return []
71
+ path = self._file.name
72
+ self._file.flush()
73
+ with open(path, "r", encoding="utf-8") as fh:
74
+ return fh.readlines()
75
+
76
+ def close(self) -> None:
77
+ if self._file is not None:
78
+ self._file.close()
79
+ self._file = None