blocklog 0.2.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 (43) hide show
  1. blocklog/__init__.py +78 -0
  2. blocklog/_global.py +20 -0
  3. blocklog/_init_fn.py +95 -0
  4. blocklog/api/approval.py +182 -0
  5. blocklog/api/compliance.py +162 -0
  6. blocklog/api/decisions.py +177 -0
  7. blocklog/api/incidents.py +306 -0
  8. blocklog/api/replay.py +285 -0
  9. blocklog/api/traces.py +137 -0
  10. blocklog/api/verify.py +100 -0
  11. blocklog/approval.py +119 -0
  12. blocklog/async_client.py +28 -0
  13. blocklog/batching/buffer.py +22 -0
  14. blocklog/client.py +194 -0
  15. blocklog/compliance.py +95 -0
  16. blocklog/config.py +17 -0
  17. blocklog/context/managers.py +15 -0
  18. blocklog/context/vars.py +13 -0
  19. blocklog/decorators/__init__.py +4 -0
  20. blocklog/decorators/agent.py +219 -0
  21. blocklog/decorators/tool.py +206 -0
  22. blocklog/incident.py +89 -0
  23. blocklog/integrations/langchain.py +129 -0
  24. blocklog/integrations/langgraph.py +3 -0
  25. blocklog/integrations/openai_agents.py +3 -0
  26. blocklog/managers/__init__.py +3 -0
  27. blocklog/managers/decision.py +336 -0
  28. blocklog/middleware/hooks.py +11 -0
  29. blocklog/models/events.py +33 -0
  30. blocklog/models/responses.py +18 -0
  31. blocklog/replay.py +64 -0
  32. blocklog/signing/canonical.py +5 -0
  33. blocklog/signing/ed25519.py +25 -0
  34. blocklog/transport/auth.py +8 -0
  35. blocklog/transport/httpx_async.py +39 -0
  36. blocklog/transport/httpx_sync.py +36 -0
  37. blocklog/transport/retry.py +26 -0
  38. blocklog/verify.py +72 -0
  39. blocklog-0.2.0.dist-info/METADATA +272 -0
  40. blocklog-0.2.0.dist-info/RECORD +43 -0
  41. blocklog-0.2.0.dist-info/WHEEL +5 -0
  42. blocklog-0.2.0.dist-info/licenses/LICENSE +21 -0
  43. blocklog-0.2.0.dist-info/top_level.txt +1 -0
blocklog/__init__.py ADDED
@@ -0,0 +1,78 @@
1
+ """
2
+ blocklog — Infrastructure for AI Decision-Making
3
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
4
+
5
+ Record every decision your AI agents make.
6
+
7
+ Quick start::
8
+
9
+ import blocklog
10
+
11
+ blocklog.init(api_key="blk_...")
12
+
13
+ @blocklog.agent(name="stock-trader")
14
+ def run():
15
+ with blocklog.decision(type="BUY", asset="TSLA") as d:
16
+ price = fetch_price("TSLA")
17
+ d.record_input(price=price)
18
+
19
+ order = place_order("TSLA", qty=100)
20
+ d.record_output(order_id=order.id)
21
+
22
+ Public API
23
+ ----------
24
+ ``blocklog.init()`` — configure the SDK
25
+ ``@blocklog.agent`` — trace an AI agent function or class
26
+ ``@blocklog.tool`` — record a tool call with inputs/outputs
27
+ ``blocklog.decision()`` — context manager for AI decision recording
28
+ """
29
+ from __future__ import annotations
30
+
31
+ # ── Tier 1: Getting Started ───────────────────────────────────────────────────
32
+ from blocklog._init_fn import init
33
+ from blocklog.decorators.agent import agent
34
+ from blocklog.decorators.tool import tool
35
+ from blocklog.managers.decision import decision, DecisionContext
36
+
37
+ # ── Tier 2: Governance & Investigation ────────────────────────────────────────
38
+ from blocklog import approval # noqa: E402
39
+ from blocklog.replay import replay # noqa: E402
40
+
41
+ __version__ = "0.2.0"
42
+
43
+ # Expose only the minimum concepts required to understand Blocklog.
44
+ # Advanced features (incident, verify, compliance, clients) are hidden
45
+ # from quickstart autocomplete but remain accessible via their submodules.
46
+ __all__ = [
47
+ # Tier 1
48
+ "init",
49
+ "agent",
50
+ "tool",
51
+ "decision",
52
+ "DecisionContext",
53
+ # Tier 2
54
+ "approval",
55
+ "replay",
56
+ ]
57
+
58
+ def __getattr__(name: str):
59
+ # Backward compatibility for direct client access (hidden from autocomplete)
60
+ if name == "BlocklogClient":
61
+ from blocklog.client import BlocklogClient
62
+ return BlocklogClient
63
+ if name == "AsyncBlocklogClient":
64
+ from blocklog.async_client import AsyncBlocklogClient
65
+ return AsyncBlocklogClient
66
+ if name == "BlocklogConfig":
67
+ from blocklog.config import BlocklogConfig
68
+ return BlocklogConfig
69
+ if name == "agent_session":
70
+ from blocklog.context.managers import agent_session
71
+ return agent_session
72
+ if name == "get_context":
73
+ from blocklog.context.vars import get_context
74
+ return get_context
75
+ if name == "set_context":
76
+ from blocklog.context.vars import set_context
77
+ return set_context
78
+ raise AttributeError(f"module 'blocklog' has no attribute {name!r}")
blocklog/_global.py ADDED
@@ -0,0 +1,20 @@
1
+ from typing import Optional, TYPE_CHECKING
2
+
3
+ if TYPE_CHECKING:
4
+ from blocklog.client import BlocklogClient
5
+
6
+ _global_client: Optional["BlocklogClient"] = None
7
+
8
+ def get_client() -> "BlocklogClient":
9
+ if _global_client is None:
10
+ raise RuntimeError(
11
+ "Blocklog API key not configured.\n\n"
12
+ "Use:\n"
13
+ "from blocklog import init\n"
14
+ "init(api_key=\"YOUR_API_KEY\")"
15
+ )
16
+ return _global_client
17
+
18
+ def set_client(client: "BlocklogClient") -> None:
19
+ global _global_client
20
+ _global_client = client
blocklog/_init_fn.py ADDED
@@ -0,0 +1,95 @@
1
+ """
2
+ blocklog._init_fn
3
+ ~~~~~~~~~~~~~~~~~
4
+ Implements the top-level ``blocklog.init()`` call.
5
+
6
+ Usage::
7
+
8
+ import blocklog
9
+ blocklog.init(api_key="blk_...")
10
+
11
+ # or via environment variable BLOCKLOG_API_KEY
12
+ blocklog.init()
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from typing import TYPE_CHECKING
17
+
18
+ if TYPE_CHECKING:
19
+ from blocklog.client import BlocklogClient
20
+
21
+
22
+ def init(
23
+ api_key: str | None = None,
24
+ *,
25
+ base_url: str | None = None,
26
+ signing_key: str | None = None,
27
+ timeout: float | None = None,
28
+ max_retries: int | None = None,
29
+ debug: bool = False,
30
+ ) -> "BlocklogClient":
31
+ """Initialise the Blocklog SDK.
32
+
33
+ Call once at application startup — typically right after your other
34
+ infrastructure initialisation (logging, config loading, etc.).
35
+
36
+ Parameters
37
+ ----------
38
+ api_key:
39
+ Your Blocklog API key. Falls back to the ``BLOCKLOG_API_KEY``
40
+ environment variable when omitted.
41
+ base_url:
42
+ Override the default API base URL. Useful for self-hosted
43
+ deployments. Falls back to ``BLOCKLOG_BASE_URL``.
44
+ signing_key:
45
+ Ed25519 private key used to sign log payloads for tamper-evidence.
46
+ Falls back to ``BLOCKLOG_SDK_SIGNING_KEY``.
47
+ timeout:
48
+ Per-request timeout in seconds (default: 10).
49
+ max_retries:
50
+ Number of automatic retries on transient failures (default: 3).
51
+ debug:
52
+ When ``True``, logs every outbound request to stderr.
53
+
54
+ Returns
55
+ -------
56
+ BlocklogClient
57
+ The configured global client instance. You normally don't need
58
+ to store this — all module-level helpers (``decision``,
59
+ ``approval``, ``incident``, ``replay``, ``verify``,
60
+ ``compliance``) pick it up automatically.
61
+
62
+ Examples
63
+ --------
64
+ >>> import blocklog
65
+ >>> blocklog.init(api_key="blk_live_...")
66
+
67
+ >>> # Environment-variable driven (CI / production)
68
+ >>> blocklog.init()
69
+ """
70
+ from blocklog._global import set_client
71
+ from blocklog.client import BlocklogClient
72
+ from blocklog.config import BlocklogConfig
73
+
74
+ overrides: dict = {}
75
+ if api_key is not None:
76
+ overrides["api_key"] = api_key
77
+ if base_url is not None:
78
+ overrides["base_url"] = base_url
79
+ if signing_key is not None:
80
+ overrides["signing_key"] = signing_key
81
+ if timeout is not None:
82
+ overrides["timeout"] = timeout
83
+ if max_retries is not None:
84
+ overrides["max_retries"] = max_retries
85
+
86
+ config = BlocklogConfig(**overrides)
87
+
88
+ if debug:
89
+ import logging
90
+ logging.basicConfig(level=logging.DEBUG)
91
+ logging.getLogger("blocklog").setLevel(logging.DEBUG)
92
+
93
+ client = BlocklogClient(config)
94
+ set_client(client)
95
+ return client
@@ -0,0 +1,182 @@
1
+ """
2
+ blocklog.api.approval
3
+ ~~~~~~~~~~~~~~~~~~~~~
4
+ Layer 2 client for Human-in-the-Loop (HITL) approval workflows.
5
+
6
+ Available via ``client.approval.*``.
7
+
8
+ Backend endpoints
9
+ -----------------
10
+ - POST /api/v1/hitl/approve
11
+ - POST /api/v1/hitl/reject
12
+ - POST /api/v1/hitl/escalate
13
+ - GET /api/v1/hitl/overrides
14
+ - GET /api/v1/hitl/overrides/{id}
15
+ - GET /api/v1/hitl/audit-trail
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from typing import TYPE_CHECKING, Any
20
+
21
+ if TYPE_CHECKING:
22
+ from blocklog.client import BlocklogClient
23
+
24
+
25
+ class ApprovalClient:
26
+ """Manage human approval workflows (HITL).
27
+
28
+ Accessed as ``client.approval``.
29
+
30
+ Examples
31
+ --------
32
+ >>> client.approval.request(
33
+ ... decision_id="dec_abc123",
34
+ ... reason="Trade exceeds $500k threshold",
35
+ ... reviewer="risk-team@fund.com",
36
+ ... )
37
+ """
38
+
39
+ def __init__(self, client: "BlocklogClient") -> None:
40
+ self._client = client
41
+
42
+ def request(
43
+ self,
44
+ decision_id: str | None = None,
45
+ *,
46
+ reason: str,
47
+ reviewer: str | None = None,
48
+ log_id: str | None = None,
49
+ metadata: dict[str, Any] | None = None,
50
+ ) -> dict[str, Any]:
51
+ """Request human approval for a decision or log entry.
52
+
53
+ This is a **non-blocking** operation. It registers the approval
54
+ request in Blocklog and triggers any configured webhooks or
55
+ notification rules. Execution continues immediately.
56
+
57
+ Parameters
58
+ ----------
59
+ decision_id:
60
+ UUID of the decision requiring approval.
61
+ reason:
62
+ Human-readable explanation of why approval is needed.
63
+ reviewer:
64
+ Email or identifier of the intended reviewer.
65
+ log_id:
66
+ UUID of the specific log entry, if approval is on a log
67
+ rather than a top-level decision.
68
+ metadata:
69
+ Extra context to attach to the approval request.
70
+
71
+ Returns
72
+ -------
73
+ dict
74
+ Backend response confirming the request was registered.
75
+ """
76
+ payload: dict[str, Any] = {"reason": reason}
77
+ if decision_id is not None:
78
+ payload["decision_id"] = decision_id
79
+ if reviewer is not None:
80
+ payload["reviewer"] = reviewer
81
+ if log_id is not None:
82
+ payload["log_id"] = log_id
83
+ if metadata is not None:
84
+ payload["metadata"] = metadata
85
+
86
+ return self._client.retry.run(
87
+ lambda: self._client.transport.request("POST", "/hitl/approve", json=payload)
88
+ )
89
+
90
+ def reject(
91
+ self,
92
+ reviewer: str,
93
+ *,
94
+ reason: str,
95
+ decision_id: str | None = None,
96
+ ) -> dict[str, Any]:
97
+ """Record that a reviewer has rejected a decision.
98
+
99
+ Parameters
100
+ ----------
101
+ reviewer:
102
+ Identity of the person rejecting.
103
+ reason:
104
+ Explanation for the rejection.
105
+ decision_id:
106
+ Optional reference to the decision being rejected.
107
+ """
108
+ payload: dict[str, Any] = {
109
+ "reviewer": reviewer,
110
+ "rejection_reason": reason,
111
+ }
112
+ if decision_id is not None:
113
+ payload["decision_id"] = decision_id
114
+
115
+ return self._client.retry.run(
116
+ lambda: self._client.transport.request("POST", "/hitl/reject", json=payload)
117
+ )
118
+
119
+ def escalate(
120
+ self,
121
+ from_reviewer: str,
122
+ to_reviewer: str,
123
+ *,
124
+ reason: str,
125
+ ) -> dict[str, Any]:
126
+ """Escalate an approval request to a different reviewer.
127
+
128
+ Parameters
129
+ ----------
130
+ from_reviewer:
131
+ Current reviewer escalating the decision.
132
+ to_reviewer:
133
+ Target reviewer who should take over.
134
+ reason:
135
+ Explanation for the escalation.
136
+ """
137
+ return self._client.retry.run(
138
+ lambda: self._client.transport.request("POST", "/hitl/escalate", json={
139
+ "current_reviewer": from_reviewer,
140
+ "escalation_target": to_reviewer,
141
+ "escalation_reason": reason,
142
+ })
143
+ )
144
+
145
+ def list_overrides(self) -> list[dict[str, Any]]:
146
+ """Return all HITL override records for the company.
147
+
148
+ Returns
149
+ -------
150
+ list[dict]
151
+ List of override records (approvals that changed an outcome).
152
+ """
153
+ return self._client.retry.run(
154
+ lambda: self._client.transport.request("GET", "/hitl/overrides")
155
+ )
156
+
157
+ def get_override(self, override_id: int) -> dict[str, Any]:
158
+ """Fetch a specific HITL override record.
159
+
160
+ Parameters
161
+ ----------
162
+ override_id:
163
+ Integer ID of the override.
164
+ """
165
+ return self._client.retry.run(
166
+ lambda: self._client.transport.request("GET", f"/hitl/overrides/{override_id}")
167
+ )
168
+
169
+ def audit_trail(self) -> list[dict[str, Any]]:
170
+ """Return the full HITL audit trail for the company.
171
+
172
+ Includes all approve / reject / escalate events in reverse
173
+ chronological order.
174
+
175
+ Returns
176
+ -------
177
+ list[dict]
178
+ Ordered list of HITL audit log entries.
179
+ """
180
+ return self._client.retry.run(
181
+ lambda: self._client.transport.request("GET", "/hitl/audit-trail")
182
+ )
@@ -0,0 +1,162 @@
1
+ """
2
+ blocklog.api.compliance
3
+ ~~~~~~~~~~~~~~~~~~~~~~~
4
+ Layer 2 client for compliance report generation.
5
+
6
+ Available via ``client.compliance.*``.
7
+
8
+ Backend endpoints
9
+ -----------------
10
+ - GET /api/v1/compliance/dashboard
11
+ - GET /api/v1/compliance/reports
12
+ - POST /api/v1/compliance/reports
13
+ - GET /api/v1/compliance/reports/{id}
14
+ - POST /api/v1/compliance/reports/{id}/share
15
+ - GET /api/v1/compliance/reports/{id}/export
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from typing import TYPE_CHECKING, Any
20
+
21
+ if TYPE_CHECKING:
22
+ from blocklog.client import BlocklogClient
23
+
24
+
25
+ class ComplianceClient:
26
+ """Generate and manage compliance reports.
27
+
28
+ Accessed as ``client.compliance``.
29
+
30
+ Examples
31
+ --------
32
+ >>> report = client.compliance.generate(
33
+ ... trace_id="trace-abc",
34
+ ... framework="SOC2",
35
+ ... )
36
+ >>> share_url = client.compliance.share(report["id"], expires_in=86400)
37
+ """
38
+
39
+ def __init__(self, client: "BlocklogClient") -> None:
40
+ self._client = client
41
+
42
+ def generate(
43
+ self,
44
+ trace_id: str | None = None,
45
+ *,
46
+ framework: str | None = None,
47
+ date_from: str | None = None,
48
+ date_to: str | None = None,
49
+ metadata: dict[str, Any] | None = None,
50
+ ) -> dict[str, Any]:
51
+ """Generate a compliance report.
52
+
53
+ Parameters
54
+ ----------
55
+ trace_id:
56
+ Scope the report to a specific trace. Omit to generate a
57
+ company-wide report.
58
+ framework:
59
+ Compliance framework (``"SOC2"``, ``"GDPR"``, ``"ISO27001"``…).
60
+ date_from:
61
+ ISO-8601 start of the reporting window.
62
+ date_to:
63
+ ISO-8601 end of the reporting window.
64
+ metadata:
65
+ Arbitrary extra data to embed in the report.
66
+
67
+ Returns
68
+ -------
69
+ dict
70
+ The generated report record.
71
+ """
72
+ payload: dict[str, Any] = {}
73
+ if trace_id is not None:
74
+ payload["trace_id"] = trace_id
75
+ if framework is not None:
76
+ payload["framework"] = framework
77
+ if date_from is not None:
78
+ payload["date_from"] = date_from
79
+ if date_to is not None:
80
+ payload["date_to"] = date_to
81
+ if metadata is not None:
82
+ payload["metadata"] = metadata
83
+
84
+ return self._client.retry.run(
85
+ lambda: self._client.transport.request("POST", "/compliance/reports", json=payload)
86
+ )
87
+
88
+ def get(self, report_id: str) -> dict[str, Any]:
89
+ """Fetch a compliance report by ID."""
90
+ return self._client.retry.run(
91
+ lambda: self._client.transport.request("GET", f"/compliance/reports/{report_id}")
92
+ )
93
+
94
+ def list(self) -> list[dict[str, Any]]:
95
+ """List all compliance reports for the company."""
96
+ result = self._client.retry.run(
97
+ lambda: self._client.transport.request("GET", "/compliance/reports")
98
+ )
99
+ return result.get("items", result) if isinstance(result, dict) else result
100
+
101
+ def dashboard(self) -> dict[str, Any]:
102
+ """Return the compliance dashboard summary."""
103
+ return self._client.retry.run(
104
+ lambda: self._client.transport.request("GET", "/compliance/dashboard")
105
+ )
106
+
107
+ def share(
108
+ self,
109
+ report_id: str,
110
+ *,
111
+ expires_in: int | None = None,
112
+ recipient_email: str | None = None,
113
+ ) -> dict[str, Any]:
114
+ """Create a shareable link for a compliance report.
115
+
116
+ Parameters
117
+ ----------
118
+ report_id:
119
+ UUID of the report to share.
120
+ expires_in:
121
+ Seconds until the share link expires.
122
+ recipient_email:
123
+ Optional email of the recipient (for audit purposes).
124
+
125
+ Returns
126
+ -------
127
+ dict
128
+ Response including ``share_url`` or ``token``.
129
+ """
130
+ payload: dict[str, Any] = {}
131
+ if expires_in is not None:
132
+ payload["expires_in"] = expires_in
133
+ if recipient_email is not None:
134
+ payload["recipient_email"] = recipient_email
135
+
136
+ return self._client.retry.run(
137
+ lambda: self._client.transport.request("POST", f"/compliance/reports/{report_id}/share", json=payload)
138
+ )
139
+
140
+ def export(
141
+ self,
142
+ report_id: str,
143
+ *,
144
+ download: bool = False,
145
+ ) -> dict[str, Any]:
146
+ """Export a compliance report as JSON.
147
+
148
+ Parameters
149
+ ----------
150
+ report_id:
151
+ UUID of the report to export.
152
+ download:
153
+ When ``True``, request the binary download stream (returned
154
+ as raw content from the backend).
155
+ """
156
+ return self._client.retry.run(
157
+ lambda: self._client.transport.request(
158
+ "GET",
159
+ f"/compliance/reports/{report_id}/export",
160
+ params={"download": str(download).lower()},
161
+ )
162
+ )