fact0-sdk 1.0.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.
auditlog/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """Deprecated auditlog package - re-exports from fact0."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings
6
+
7
+ from .client import (AuditLogError, Client, Transport, TransportError,
8
+ ValidationError)
9
+
10
+ warnings.warn(
11
+ "The auditlog package is deprecated; use `pip install fact0-sdk` and `import fact0`.",
12
+ DeprecationWarning,
13
+ stacklevel=2,
14
+ )
15
+
16
+ __all__ = ["Client", "Transport", "TransportError", "ValidationError", "AuditLogError"]
17
+ __all__ = ["Client", "Transport", "TransportError", "ValidationError", "AuditLogError"]
auditlog/client.py ADDED
@@ -0,0 +1,53 @@
1
+ """Deprecated auditlog.Client - thin wrapper around fact0.audit.client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings
6
+ from typing import Any
7
+
8
+ from fact0._http import SyncHTTP, env_base_url
9
+ from fact0.audit.client import AuditClient
10
+ from fact0.exceptions import AuditLogError, TransportError, ValidationError
11
+
12
+ warnings.warn(
13
+ "The auditlog package is deprecated; use `pip install fact0-sdk` and `import fact0`.",
14
+ DeprecationWarning,
15
+ stacklevel=2,
16
+ )
17
+
18
+
19
+ class Client:
20
+ """Back-compat audit-only client (api_key + base_url constructor)."""
21
+
22
+ def __init__(
23
+ self,
24
+ api_key: str,
25
+ *,
26
+ base_url: str | None = None,
27
+ sync: bool = False,
28
+ transport: Any | None = None,
29
+ **audit_kwargs: Any,
30
+ ):
31
+ resolved_base = (base_url or env_base_url()).rstrip("/")
32
+ if transport is not None and isinstance(transport, SyncHTTP):
33
+ http = transport
34
+ else:
35
+ http = SyncHTTP(resolved_base, api_key, sync_ingest=sync)
36
+ if transport is not None:
37
+ audit_kwargs.setdefault("transport", transport)
38
+ self._audit = AuditClient(http, **audit_kwargs)
39
+
40
+ def log(self, **kwargs: Any) -> None:
41
+ self._audit.log(**kwargs)
42
+
43
+ def flush(self) -> None:
44
+ self._audit.flush()
45
+
46
+ def close(self) -> None:
47
+ self._audit.close()
48
+
49
+
50
+ # Legacy alias: auditlog.Transport was SyncHTTP with positional url/key args.
51
+ Transport = SyncHTTP
52
+
53
+ __all__ = ["Client", "Transport", "TransportError", "ValidationError", "AuditLogError"]
fact0/__init__.py ADDED
@@ -0,0 +1,101 @@
1
+ """Fact0 Python SDK - audit log and execution telemetry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ from ._http import SyncHTTP, env_api_key, env_base_url
8
+ from .audit.async_client import AsyncAuditClient
9
+ from .audit.client import AuditClient
10
+ from .audit.models import Actor, ActorType, Outcome, Resource
11
+ from .audit.transport import AuditTransport
12
+ from .exceptions import (
13
+ AuditLogError,
14
+ Fact0Error,
15
+ TransportError,
16
+ ValidationError,
17
+ )
18
+ from .telemetry.client import TelemetryClient
19
+
20
+ __version__ = "1.0.0"
21
+
22
+ __all__ = [
23
+ "Client",
24
+ "AsyncClient",
25
+ "AuditClient",
26
+ "AsyncAuditClient",
27
+ "TelemetryClient",
28
+ "Actor",
29
+ "Resource",
30
+ "Outcome",
31
+ "ActorType",
32
+ "Fact0Error",
33
+ "AuditLogError",
34
+ "ValidationError",
35
+ "TransportError",
36
+ "__version__",
37
+ ]
38
+
39
+
40
+ class Client:
41
+ """Unified Fact0 client with audit and telemetry modules."""
42
+
43
+ def __init__(
44
+ self,
45
+ api_key: str | None = None,
46
+ *,
47
+ base_url: str | None = None,
48
+ sync: bool = False,
49
+ **audit_kwargs,
50
+ ):
51
+ resolved_key = api_key or env_api_key() or ""
52
+ resolved_base = (base_url or env_base_url()).rstrip("/")
53
+ custom_transport = audit_kwargs.pop("transport", None)
54
+ if custom_transport is not None and isinstance(custom_transport, SyncHTTP):
55
+ http = custom_transport
56
+ else:
57
+ http = SyncHTTP(resolved_base, resolved_key or None, sync_ingest=sync)
58
+ if custom_transport is not None:
59
+ audit_kwargs["transport"] = custom_transport
60
+ self._http = http
61
+ self.audit = AuditClient(http, **audit_kwargs)
62
+ self.telemetry = TelemetryClient(http)
63
+
64
+ def close(self) -> None:
65
+ self.audit.close()
66
+
67
+ def log(self, **kwargs) -> None:
68
+ self.audit.log(**kwargs)
69
+
70
+ def flush(self) -> None:
71
+ self.audit.flush()
72
+
73
+
74
+ class AsyncClient:
75
+ """Async Fact0 client."""
76
+
77
+ def __init__(
78
+ self,
79
+ api_key: str | None = None,
80
+ *,
81
+ base_url: str | None = None,
82
+ sync: bool = False,
83
+ ):
84
+ resolved_key = api_key or env_api_key() or ""
85
+ if not resolved_key:
86
+ raise ValueError("api_key is required for AsyncClient audit operations")
87
+ resolved_base = (base_url or env_base_url()).rstrip("/")
88
+ self.audit = AsyncAuditClient(resolved_base, resolved_key, sync=sync)
89
+ self._base_url = resolved_base
90
+ self.telemetry = TelemetryClient(
91
+ SyncHTTP(resolved_base, resolved_key, sync_ingest=sync)
92
+ )
93
+
94
+ async def close(self) -> None:
95
+ await self.audit.close()
96
+
97
+ async def __aenter__(self) -> AsyncClient:
98
+ return self
99
+
100
+ async def __aexit__(self, *args) -> None:
101
+ await self.close()
fact0/_http.py ADDED
@@ -0,0 +1,125 @@
1
+ """Shared HTTP utilities with retries and auth."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import time
8
+ from typing import Any, Mapping
9
+
10
+ import requests
11
+
12
+ from .exceptions import TransportError
13
+
14
+ _log = logging.getLogger("fact0")
15
+
16
+ _RETRYABLE = {429, 500, 502, 503, 504}
17
+ DEFAULT_TIMEOUT_S = 30.0
18
+ USER_AGENT = "fact0-python/1.0.0"
19
+
20
+
21
+ def env_base_url(default: str = "https://api.fact0.io") -> str:
22
+ return (
23
+ os.environ.get("FACT0_BASE_URL")
24
+ or os.environ.get("FACT0_BASE_URL")
25
+ or default
26
+ ).rstrip("/")
27
+
28
+
29
+ def env_api_key() -> str | None:
30
+ return (
31
+ os.environ.get("FACT0_API_KEY")
32
+ or os.environ.get("FACT0_API_KEY")
33
+ or os.environ.get("AUDITLOG_API_KEY")
34
+ )
35
+
36
+
37
+ class SyncHTTP:
38
+ """Synchronous HTTP client with bounded retries."""
39
+
40
+ def __init__(
41
+ self,
42
+ base_url: str,
43
+ api_key: str | None = None,
44
+ *,
45
+ timeout_s: float = DEFAULT_TIMEOUT_S,
46
+ max_retries: int = 3,
47
+ backoff_base_s: float = 0.2,
48
+ sync_ingest: bool = False,
49
+ session: requests.Session | None = None,
50
+ ):
51
+ self.base_url = base_url.rstrip("/")
52
+ self.api_key = api_key
53
+ self.timeout_s = timeout_s
54
+ self.max_retries = max_retries
55
+ self.backoff_base_s = backoff_base_s
56
+ self.sync_ingest = sync_ingest
57
+ self.session = session or requests.Session()
58
+
59
+ def _headers(self, *, auth: bool = True, sync: bool | None = None) -> dict[str, str]:
60
+ headers = {
61
+ "Content-Type": "application/json",
62
+ "User-Agent": USER_AGENT,
63
+ }
64
+ if auth and self.api_key:
65
+ headers["Authorization"] = f"Bearer {self.api_key}"
66
+ use_sync = self.sync_ingest if sync is None else sync
67
+ if use_sync:
68
+ headers["X-Fact0-Sync"] = "true"
69
+ return headers
70
+
71
+ def request(
72
+ self,
73
+ method: str,
74
+ path: str,
75
+ *,
76
+ json_body: Any | None = None,
77
+ params: Mapping[str, Any] | None = None,
78
+ auth: bool = True,
79
+ sync: bool | None = None,
80
+ expect_json: bool = True,
81
+ ) -> Any:
82
+ url = f"{self.base_url}{path}"
83
+ last_err: Exception | None = None
84
+ for attempt in range(self.max_retries + 1):
85
+ try:
86
+ resp = self.session.request(
87
+ method,
88
+ url,
89
+ json=json_body,
90
+ params=params,
91
+ headers=self._headers(auth=auth, sync=sync),
92
+ timeout=self.timeout_s,
93
+ )
94
+ except requests.RequestException as exc:
95
+ last_err = exc
96
+ _log.warning("network error (attempt %d): %s", attempt + 1, exc)
97
+ else:
98
+ retry_after = resp.headers.get("Retry-After")
99
+ if resp.status_code < 300:
100
+ if not expect_json:
101
+ return resp.content
102
+ try:
103
+ return resp.json()
104
+ except ValueError:
105
+ return {}
106
+ if resp.status_code not in _RETRYABLE:
107
+ raise TransportError(
108
+ f"{method} {path} returned {resp.status_code}: {resp.text[:200]}",
109
+ status_code=resp.status_code,
110
+ )
111
+ last_err = TransportError(
112
+ f"{method} {path} returned {resp.status_code}",
113
+ status_code=resp.status_code,
114
+ )
115
+ if retry_after:
116
+ try:
117
+ time.sleep(float(retry_after))
118
+ continue
119
+ except ValueError:
120
+ pass
121
+
122
+ if attempt < self.max_retries:
123
+ time.sleep(self.backoff_base_s * (2**attempt))
124
+
125
+ raise TransportError(f"giving up after {self.max_retries + 1} attempts: {last_err}")
File without changes
@@ -0,0 +1,129 @@
1
+ """Async audit client using httpx."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import uuid
9
+ from datetime import datetime
10
+ from typing import Any, Optional
11
+
12
+ import httpx
13
+ from pydantic import ValidationError as PydanticValidationError
14
+
15
+ from .._http import DEFAULT_TIMEOUT_S, USER_AGENT
16
+ from ..exceptions import TransportError, ValidationError
17
+ from .models import Event
18
+
19
+ _log = logging.getLogger("fact0")
20
+
21
+ _RETRYABLE = {429, 500, 502, 503, 504}
22
+
23
+
24
+ class AsyncAuditClient:
25
+ def __init__(
26
+ self,
27
+ base_url: str,
28
+ api_key: str,
29
+ *,
30
+ timeout_s: float = DEFAULT_TIMEOUT_S,
31
+ sync: bool = False,
32
+ client: httpx.AsyncClient | None = None,
33
+ ):
34
+ self.base_url = base_url.rstrip("/")
35
+ self.api_key = api_key
36
+ self.sync = sync
37
+ self._client = client
38
+ self._owned = client is None
39
+ self.timeout_s = timeout_s
40
+ self._buf: list[dict[str, Any]] = []
41
+
42
+ async def _get_client(self) -> httpx.AsyncClient:
43
+ if self._client is None:
44
+ self._client = httpx.AsyncClient(timeout=self.timeout_s)
45
+ return self._client
46
+
47
+ def _headers(self) -> dict[str, str]:
48
+ headers = {
49
+ "Authorization": f"Bearer {self.api_key}",
50
+ "Content-Type": "application/json",
51
+ "User-Agent": USER_AGENT,
52
+ }
53
+ if self.sync:
54
+ headers["X-Fact0-Sync"] = "true"
55
+ return headers
56
+
57
+ async def _request(
58
+ self,
59
+ method: str,
60
+ path: str,
61
+ *,
62
+ json_body: Any | None = None,
63
+ params: dict[str, Any] | None = None,
64
+ expect_json: bool = True,
65
+ ) -> Any:
66
+ client = await self._get_client()
67
+ url = f"{self.base_url}{path}"
68
+ resp = await client.request(method, url, json=json_body, params=params, headers=self._headers())
69
+ if resp.status_code >= 300:
70
+ raise TransportError(f"{method} {path} -> {resp.status_code}", status_code=resp.status_code)
71
+ if not expect_json:
72
+ return resp.content
73
+ try:
74
+ return resp.json()
75
+ except ValueError:
76
+ return {}
77
+
78
+ async def log(self, **fields: Any) -> None:
79
+ wire = self._validate(**fields)
80
+ self._buf.append(wire)
81
+
82
+ async def flush(self) -> None:
83
+ if not self._buf:
84
+ return
85
+ chunk, self._buf = self._buf, []
86
+ await self._request("POST", "/v1/events/batch", json_body={"events": chunk})
87
+
88
+ async def close(self) -> None:
89
+ await self.flush()
90
+ if self._owned and self._client is not None:
91
+ await self._client.aclose()
92
+ self._client = None
93
+
94
+ async def get_event(self, event_id: str) -> dict[str, Any]:
95
+ return await self._request("GET", f"/v1/events/{event_id}")
96
+
97
+ async def list_events(self, **filters: Any) -> dict[str, Any]:
98
+ return await self._request("GET", "/v1/events", params={k: v for k, v in filters.items() if v is not None})
99
+
100
+ async def verify(self, **params: Any) -> dict[str, Any]:
101
+ return await self._request("GET", "/v1/verify", params={k: v for k, v in params.items() if v is not None})
102
+
103
+ async def verify_event(self, event_id: str) -> dict[str, Any]:
104
+ return await self._request("GET", f"/v1/events/{event_id}/verify")
105
+
106
+ async def get_receipt(self, receipt_id: str) -> dict[str, Any]:
107
+ return await self._request("GET", f"/v1/receipts/{receipt_id}")
108
+
109
+ async def wait_for_receipt(self, receipt_id: str, *, timeout_s: float = 30.0) -> dict[str, Any]:
110
+ import time
111
+
112
+ deadline = time.monotonic() + timeout_s
113
+ while time.monotonic() < deadline:
114
+ body = await self.get_receipt(receipt_id)
115
+ if body.get("status") in ("committed", "failed"):
116
+ return body
117
+ await asyncio.sleep(0.2)
118
+ raise TransportError(f"receipt {receipt_id} not settled within {timeout_s}s")
119
+
120
+ def _validate(self, **kwargs: Any) -> dict[str, Any]:
121
+ if kwargs.get("event_id"):
122
+ kwargs["id"] = kwargs.pop("event_id")
123
+ elif "id" not in kwargs:
124
+ kwargs["id"] = str(uuid.uuid4())
125
+ try:
126
+ evt = Event(**kwargs)
127
+ except PydanticValidationError as exc:
128
+ raise ValidationError(str(exc)) from exc
129
+ return evt.model_dump(exclude_none=True, mode="json")
fact0/audit/client.py ADDED
@@ -0,0 +1,220 @@
1
+ """Synchronous audit log client with background batching."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import atexit
6
+ import json
7
+ import logging
8
+ import threading
9
+ import uuid
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+ from typing import Any, Iterator, Optional
13
+
14
+ from pydantic import ValidationError as PydanticValidationError
15
+
16
+ from .._http import DEFAULT_TIMEOUT_S, SyncHTTP
17
+ from ..exceptions import AuditLogError, TransportError, ValidationError
18
+ from .models import Event
19
+ from .transport import AuditTransport
20
+
21
+ _log = logging.getLogger("fact0")
22
+
23
+ DEFAULT_BATCH_MAX_SIZE = 100
24
+ DEFAULT_BATCH_MAX_WAIT_MS = 500
25
+
26
+
27
+ class AuditClient:
28
+ """Buffered, thread-safe audit log client."""
29
+
30
+ def __init__(
31
+ self,
32
+ http: SyncHTTP,
33
+ *,
34
+ batch_max_size: int = DEFAULT_BATCH_MAX_SIZE,
35
+ batch_max_wait_ms: int = DEFAULT_BATCH_MAX_WAIT_MS,
36
+ raise_on_error: bool = False,
37
+ dead_letter_path: Optional[str] = None,
38
+ poll_receipts: bool = True,
39
+ transport: Any | None = None,
40
+ ):
41
+ self._http = http
42
+ if transport is not None and hasattr(transport, "_audit"):
43
+ self._transport = transport._audit
44
+ elif isinstance(transport, SyncHTTP):
45
+ self._http = transport
46
+ self._transport = AuditTransport(transport)
47
+ elif transport is not None:
48
+ self._transport = transport
49
+ else:
50
+ self._transport = AuditTransport(http)
51
+ self._batch_max_size = batch_max_size
52
+ self._batch_max_wait_s = batch_max_wait_ms / 1000.0
53
+ self._raise_on_error = raise_on_error
54
+ self._dead_letter_path = dead_letter_path
55
+ self._poll_receipts = poll_receipts
56
+
57
+ self._buf: list[dict[str, Any]] = []
58
+ self._cond = threading.Condition()
59
+ self._stopped = False
60
+ self._flusher = threading.Thread(target=self._run, daemon=True, name="fact0-audit-flush")
61
+ self._flusher.start()
62
+ atexit.register(self.close)
63
+
64
+ def log(
65
+ self,
66
+ *,
67
+ actor: dict[str, Any],
68
+ action: str,
69
+ resource: dict[str, Any],
70
+ outcome: str,
71
+ metadata: Optional[dict[str, Any]] = None,
72
+ timestamp: Optional[datetime] = None,
73
+ event_id: Optional[str] = None,
74
+ ) -> None:
75
+ wire = self._validate_event(
76
+ actor=actor,
77
+ action=action,
78
+ resource=resource,
79
+ outcome=outcome,
80
+ metadata=metadata,
81
+ timestamp=timestamp,
82
+ event_id=event_id,
83
+ )
84
+ with self._cond:
85
+ was_empty = not self._buf
86
+ self._buf.append(wire)
87
+ if was_empty or len(self._buf) >= self._batch_max_size:
88
+ self._cond.notify_all()
89
+
90
+ def log_batch(self, events: list[dict[str, Any]]) -> dict[str, Any]:
91
+ validated = [self._validate_wire(e) for e in events]
92
+ return self._send(validated)
93
+
94
+ def flush(self) -> None:
95
+ while True:
96
+ with self._cond:
97
+ if not self._buf:
98
+ return
99
+ chunk = self._buf[: self._batch_max_size]
100
+ del self._buf[: self._batch_max_size]
101
+ self._send(chunk)
102
+
103
+ def close(self) -> None:
104
+ with self._cond:
105
+ if self._stopped:
106
+ return
107
+ self._stopped = True
108
+ self._cond.notify_all()
109
+ self._flusher.join(timeout=5.0)
110
+ self.flush()
111
+
112
+ def get_event(self, event_id: str) -> dict[str, Any]:
113
+ return self._http.request("GET", f"/v1/events/{event_id}")
114
+
115
+ def list_events(self, **filters: Any) -> dict[str, Any]:
116
+ params = {k: v for k, v in filters.items() if v is not None}
117
+ return self._http.request("GET", "/v1/events", params=params)
118
+
119
+ def get_receipt(self, receipt_id: str) -> dict[str, Any]:
120
+ return self._transport.get_receipt(receipt_id)
121
+
122
+ def wait_for_receipt(self, receipt_id: str, *, timeout_s: float = 30.0) -> dict[str, Any]:
123
+ return self._transport.poll_receipt(receipt_id, timeout_s=timeout_s)
124
+
125
+ def verify(self, **params: Any) -> dict[str, Any]:
126
+ return self._http.request("GET", "/v1/verify", params={k: v for k, v in params.items() if v is not None})
127
+
128
+ def verify_event(self, event_id: str) -> dict[str, Any]:
129
+ return self._http.request("GET", f"/v1/events/{event_id}/verify")
130
+
131
+ def export_pdf(self, **params: Any) -> bytes:
132
+ return self._http.request(
133
+ "GET", "/v1/export/pdf", params={k: v for k, v in params.items() if v is not None}, expect_json=False
134
+ )
135
+
136
+ def export_evidence_pack(self, **params: Any) -> bytes:
137
+ return self._http.request(
138
+ "GET",
139
+ "/v1/export/evidence-pack",
140
+ params={k: v for k, v in params.items() if v is not None},
141
+ expect_json=False,
142
+ )
143
+
144
+ def stream_events(self) -> Iterator[dict[str, Any]]:
145
+ import requests
146
+
147
+ url = f"{self._http.base_url}/v1/events/stream"
148
+ headers = self._http._headers()
149
+ headers["Accept"] = "text/event-stream"
150
+ with requests.get(url, headers=headers, stream=True, timeout=self._http.timeout_s) as resp:
151
+ resp.raise_for_status()
152
+ data_lines: list[str] = []
153
+ for raw in resp.iter_lines(decode_unicode=True):
154
+ if raw is None:
155
+ continue
156
+ if raw.startswith("data:"):
157
+ data_lines.append(raw[5:].strip())
158
+ elif raw == "" and data_lines:
159
+ payload = "".join(data_lines)
160
+ data_lines.clear()
161
+ if payload and payload != "{}":
162
+ yield json.loads(payload)
163
+
164
+ def _validate_event(self, **kwargs: Any) -> dict[str, Any]:
165
+ kwargs.pop("event_id", None)
166
+ if kwargs.get("metadata") is None:
167
+ kwargs.pop("metadata", None)
168
+ if "event_id" in kwargs and kwargs["event_id"]:
169
+ kwargs["id"] = kwargs.pop("event_id")
170
+ elif "id" not in kwargs:
171
+ kwargs["id"] = str(uuid.uuid4())
172
+ return self._validate_wire(kwargs)
173
+
174
+ def _validate_wire(self, fields: dict[str, Any]) -> dict[str, Any]:
175
+ try:
176
+ evt = Event(**fields)
177
+ except PydanticValidationError as exc:
178
+ raise ValidationError(str(exc)) from exc
179
+ return evt.model_dump(exclude_none=True, mode="json")
180
+
181
+ def _run(self) -> None:
182
+ while True:
183
+ with self._cond:
184
+ if self._stopped:
185
+ return
186
+ if not self._buf:
187
+ self._cond.wait()
188
+ if self._stopped:
189
+ return
190
+ if len(self._buf) < self._batch_max_size:
191
+ self._cond.wait(timeout=self._batch_max_wait_s)
192
+ if self._stopped:
193
+ return
194
+ chunk = self._buf[: self._batch_max_size]
195
+ del self._buf[: self._batch_max_size]
196
+ if chunk:
197
+ self._send(chunk)
198
+
199
+ def _send(self, buf: list[dict[str, Any]]) -> dict[str, Any]:
200
+ try:
201
+ result = self._transport.post_batch(buf)
202
+ receipt_id = result.get("receipt_id")
203
+ if receipt_id and self._poll_receipts and result.get("status") == "queued":
204
+ self._transport.poll_receipt(receipt_id)
205
+ return result
206
+ except (TransportError, AuditLogError) as exc:
207
+ self._dead_letter(buf, exc)
208
+ if self._raise_on_error:
209
+ raise
210
+ _log.warning("audit flush dropped %d events: %s", len(buf), exc)
211
+ return {}
212
+
213
+ def _dead_letter(self, buf: list[dict[str, Any]], exc: Exception) -> None:
214
+ if not self._dead_letter_path:
215
+ return
216
+ path = Path(self._dead_letter_path)
217
+ path.parent.mkdir(parents=True, exist_ok=True)
218
+ with path.open("a", encoding="utf-8") as f:
219
+ for evt in buf:
220
+ f.write(json.dumps({"error": str(exc), "event": evt}) + "\n")
fact0/audit/models.py ADDED
@@ -0,0 +1,57 @@
1
+ """Pydantic models for audit events."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from enum import Enum
7
+ from typing import Any, Optional
8
+
9
+ from pydantic import BaseModel, ConfigDict, Field, field_serializer
10
+
11
+
12
+ class ActorType(str, Enum):
13
+ HUMAN = "human"
14
+ AGENT = "agent"
15
+ SYSTEM = "system"
16
+
17
+
18
+ class Outcome(str, Enum):
19
+ SUCCESS = "success"
20
+ FAILURE = "failure"
21
+ ERROR = "error"
22
+
23
+
24
+ class Actor(BaseModel):
25
+ model_config = ConfigDict(extra="forbid")
26
+ id: str = Field(min_length=1)
27
+ type: ActorType
28
+ email: Optional[str] = None
29
+
30
+
31
+ class Resource(BaseModel):
32
+ model_config = ConfigDict(extra="forbid")
33
+ id: str = Field(min_length=1)
34
+ type: str = Field(min_length=1)
35
+ name: Optional[str] = None
36
+
37
+
38
+ class Event(BaseModel):
39
+ """Wire shape of an audit event."""
40
+
41
+ model_config = ConfigDict(extra="forbid")
42
+
43
+ actor: Actor
44
+ action: str = Field(min_length=1)
45
+ resource: Resource
46
+ outcome: Outcome
47
+ metadata: dict[str, Any] = Field(default_factory=dict)
48
+ id: Optional[str] = None
49
+ timestamp: Optional[datetime] = None
50
+
51
+ @field_serializer("timestamp")
52
+ def _serialize_ts(self, v: Optional[datetime]) -> Optional[str]:
53
+ if v is None:
54
+ return None
55
+ if v.tzinfo is None:
56
+ v = v.replace(tzinfo=timezone.utc)
57
+ return v.isoformat()
@@ -0,0 +1,35 @@
1
+ """Audit HTTP transport."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any
7
+
8
+ from .._http import SyncHTTP
9
+ from ..exceptions import TransportError
10
+
11
+
12
+ class AuditTransport:
13
+ def __init__(self, http: SyncHTTP):
14
+ self._http = http
15
+
16
+ def post_batch(self, events: list[dict[str, Any]]) -> dict[str, Any]:
17
+ return self._http.request("POST", "/v1/events/batch", json_body={"events": events})
18
+
19
+ def get_receipt(self, receipt_id: str) -> dict[str, Any]:
20
+ return self._http.request("GET", f"/v1/receipts/{receipt_id}")
21
+
22
+ def poll_receipt(
23
+ self,
24
+ receipt_id: str,
25
+ *,
26
+ timeout_s: float = 30.0,
27
+ interval_s: float = 0.2,
28
+ ) -> dict[str, Any]:
29
+ deadline = time.monotonic() + timeout_s
30
+ while time.monotonic() < deadline:
31
+ body = self.get_receipt(receipt_id)
32
+ if body.get("status") in ("committed", "failed"):
33
+ return body
34
+ time.sleep(interval_s)
35
+ raise TransportError(f"receipt {receipt_id} not settled within {timeout_s}s")
fact0/exceptions.py ADDED
@@ -0,0 +1,23 @@
1
+ """Public exception types raised by the Fact0 SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class Fact0Error(Exception):
7
+ """Base class for SDK errors."""
8
+
9
+
10
+ # Back-compat aliases
11
+ AuditLogError = Fact0Error
12
+
13
+
14
+ class ValidationError(Fact0Error):
15
+ """Invalid event fields or types."""
16
+
17
+
18
+ class TransportError(Fact0Error):
19
+ """HTTP failure or unreachable server."""
20
+
21
+ def __init__(self, message: str, *, status_code: int | None = None):
22
+ super().__init__(message)
23
+ self.status_code = status_code
File without changes
@@ -0,0 +1,45 @@
1
+ """FastAPI middleware for request-level audit logging."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Callable
7
+
8
+ from starlette.middleware.base import BaseHTTPMiddleware
9
+ from starlette.requests import Request
10
+ from starlette.responses import Response
11
+
12
+ from fact0.audit.client import AuditClient
13
+
14
+
15
+ class AuditMiddleware(BaseHTTPMiddleware):
16
+ """Log each HTTP request as an audit event."""
17
+
18
+ def __init__(
19
+ self,
20
+ app,
21
+ client_factory: Callable[[], AuditClient],
22
+ *,
23
+ action_prefix: str = "api",
24
+ ):
25
+ super().__init__(app)
26
+ self._client_factory = client_factory
27
+ self._action_prefix = action_prefix
28
+
29
+ async def dispatch(self, request: Request, call_next) -> Response:
30
+ start = time.perf_counter()
31
+ response = await call_next(request)
32
+ duration_ms = int((time.perf_counter() - start) * 1000)
33
+ client = self._client_factory()
34
+ client.log(
35
+ actor={"id": "api", "type": "system"},
36
+ action=f"{self._action_prefix}.request",
37
+ resource={"id": request.url.path, "type": "http.route"},
38
+ outcome="success" if response.status_code < 400 else "failure",
39
+ metadata={
40
+ "method": request.method,
41
+ "status": response.status_code,
42
+ "duration_ms": duration_ms,
43
+ },
44
+ )
45
+ return response
@@ -0,0 +1,96 @@
1
+ """LangChain callback handler for Fact0 telemetry and audit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, List, Optional
6
+ from uuid import UUID
7
+
8
+ from fact0 import Client
9
+
10
+
11
+ class Fact0CallbackHandler:
12
+ """Record LangChain runs to Fact0 telemetry (and optional audit events)."""
13
+
14
+ def __init__(
15
+ self,
16
+ client: Client,
17
+ *,
18
+ agent_id: str,
19
+ audit_sensitive_actions: bool = False,
20
+ execution_metadata: dict[str, str] | None = None,
21
+ ):
22
+ self._client = client
23
+ self._agent_id = agent_id
24
+ self._audit_sensitive = audit_sensitive_actions
25
+ self._metadata = execution_metadata or {}
26
+ self._execution_ctx = None
27
+ self._spans: dict[UUID, Any] = {}
28
+
29
+ def _ensure_execution(self):
30
+ if self._execution_ctx is None:
31
+ self._execution_ctx = self._client.telemetry.execution(
32
+ agent_id=self._agent_id,
33
+ metadata=self._metadata,
34
+ ).__enter__()
35
+ if self._audit_sensitive:
36
+ self._client.audit.log(
37
+ actor={"id": self._agent_id, "type": "agent"},
38
+ action="agent.run.start",
39
+ resource={"id": self._execution_ctx.id, "type": "agent.execution"},
40
+ outcome="success",
41
+ )
42
+
43
+ # LangChain BaseCallbackHandler-style hooks (duck typed)
44
+ def on_chain_start(self, serialized: dict, inputs: dict, **kwargs: Any) -> None:
45
+ self._ensure_execution()
46
+
47
+ def on_chain_end(self, outputs: dict, **kwargs: Any) -> None:
48
+ if self._execution_ctx and self._audit_sensitive:
49
+ self._client.audit.log(
50
+ actor={"id": self._agent_id, "type": "agent"},
51
+ action="agent.run.end",
52
+ resource={"id": self._execution_ctx.id, "type": "agent.execution"},
53
+ outcome="success",
54
+ )
55
+ if self._execution_ctx:
56
+ self._client.telemetry.end_execution(self._execution_ctx.id, "COMPLETED")
57
+ self._execution_ctx = None
58
+
59
+ def on_llm_start(self, serialized: dict, prompts: List[str], **kwargs: Any) -> None:
60
+ self._ensure_execution()
61
+ run_id = kwargs.get("run_id")
62
+ if self._execution_ctx and run_id:
63
+ span = self._execution_ctx.span(
64
+ serialized.get("name", "llm"), span_type="MODEL_INVOCATION"
65
+ )
66
+ span.__enter__()
67
+ self._spans[run_id] = span
68
+
69
+ def on_llm_end(self, response: Any, **kwargs: Any) -> None:
70
+ run_id = kwargs.get("run_id")
71
+ span = self._spans.pop(run_id, None) if run_id else None
72
+ if span:
73
+ span.complete(output={"text": str(response)[:500]})
74
+
75
+ def on_tool_start(self, serialized: dict, input_str: str, **kwargs: Any) -> None:
76
+ self._ensure_execution()
77
+ run_id = kwargs.get("run_id")
78
+ name = serialized.get("name", "tool")
79
+ if self._execution_ctx and run_id:
80
+ span = self._execution_ctx.span(name, span_type="TOOL_CALL")
81
+ span.__enter__()
82
+ self._spans[run_id] = span
83
+ if self._audit_sensitive:
84
+ self._client.audit.log(
85
+ actor={"id": self._agent_id, "type": "agent"},
86
+ action="agent.tool.call",
87
+ resource={"id": name, "type": "tool"},
88
+ outcome="success",
89
+ metadata={"input": input_str[:500]},
90
+ )
91
+
92
+ def on_tool_end(self, output: str, **kwargs: Any) -> None:
93
+ run_id = kwargs.get("run_id")
94
+ span = self._spans.pop(run_id, None) if run_id else None
95
+ if span:
96
+ span.complete(output={"result": output[:500]})
fact0/py.typed ADDED
@@ -0,0 +1 @@
1
+ py.typed
File without changes
@@ -0,0 +1,78 @@
1
+ """Execution telemetry REST client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .._http import SyncHTTP
8
+
9
+
10
+ class TelemetryClient:
11
+ def __init__(self, http: SyncHTTP):
12
+ self._http = http
13
+
14
+ def start_execution(
15
+ self,
16
+ *,
17
+ agent_id: str,
18
+ agent_name: str = "",
19
+ trigger: str = "",
20
+ metadata: dict[str, str] | None = None,
21
+ idempotency_key: str = "",
22
+ ) -> dict[str, Any]:
23
+ body: dict[str, Any] = {"agent_id": agent_id}
24
+ if agent_name:
25
+ body["agent_name"] = agent_name
26
+ if trigger:
27
+ body["trigger"] = trigger
28
+ if metadata:
29
+ body["metadata"] = metadata
30
+ if idempotency_key:
31
+ body["idempotency_key"] = idempotency_key
32
+ return self._http.request("POST", "/api/v1/executions", json_body=body)
33
+
34
+ def ingest_spans(self, execution_id: str, spans: list[dict[str, Any]]) -> dict[str, Any]:
35
+ return self._http.request(
36
+ "POST",
37
+ f"/api/v1/executions/{execution_id}/spans",
38
+ json_body={"spans": spans},
39
+ )
40
+
41
+ def ingest_events(self, execution_id: str, events: list[dict[str, Any]]) -> dict[str, Any]:
42
+ return self._http.request(
43
+ "POST",
44
+ f"/api/v1/executions/{execution_id}/events",
45
+ json_body={"events": events},
46
+ )
47
+
48
+ def end_execution(self, execution_id: str, status: str) -> dict[str, Any]:
49
+ return self._http.request(
50
+ "PUT",
51
+ f"/api/v1/executions/{execution_id}/end",
52
+ json_body={"status": status},
53
+ )
54
+
55
+ def list_executions(self, **params: Any) -> dict[str, Any]:
56
+ return self._http.request("GET", "/api/v1/executions", params=params)
57
+
58
+ def get_execution(self, execution_id: str) -> dict[str, Any]:
59
+ return self._http.request("GET", f"/api/v1/executions/{execution_id}")
60
+
61
+ def get_spans(self, execution_id: str) -> dict[str, Any]:
62
+ return self._http.request("GET", f"/api/v1/executions/{execution_id}/spans")
63
+
64
+ def get_dag(self, execution_id: str) -> dict[str, Any]:
65
+ return self._http.request("GET", f"/api/v1/executions/{execution_id}/dag")
66
+
67
+ def replay(self, execution_id: str, **params: Any) -> dict[str, Any]:
68
+ return self._http.request(
69
+ "GET", f"/api/v1/executions/{execution_id}/replay", params=params
70
+ )
71
+
72
+ def get_span(self, span_id: str) -> dict[str, Any]:
73
+ return self._http.request("GET", f"/api/v1/spans/{span_id}")
74
+
75
+ def execution(self, **kwargs: Any):
76
+ from .context import ExecutionContext
77
+
78
+ return ExecutionContext(self, **kwargs)
@@ -0,0 +1,102 @@
1
+ """Execution and span context managers for telemetry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from datetime import datetime, timezone
7
+ from typing import Any
8
+
9
+ from .client import TelemetryClient
10
+
11
+
12
+ def _utcnow() -> datetime:
13
+ return datetime.now(timezone.utc)
14
+
15
+
16
+ class SpanContext:
17
+ def __init__(self, tel: TelemetryClient, execution_id: str, name: str, span_type: str):
18
+ self._tel = tel
19
+ self._execution_id = execution_id
20
+ self._span_id = f"span_{uuid.uuid4().hex[:16]}"
21
+ self._name = name
22
+ self._span_type = span_type
23
+ self._started = _utcnow()
24
+ self._ended = False
25
+
26
+ def log_event(self, event_type: str, payload: dict[str, Any]) -> None:
27
+ self._tel.ingest_events(
28
+ self._execution_id,
29
+ [
30
+ {
31
+ "id": f"evt_{uuid.uuid4().hex[:16]}",
32
+ "execution_id": self._execution_id,
33
+ "span_id": self._span_id,
34
+ "event_type": event_type,
35
+ "timestamp": _utcnow().isoformat(),
36
+ "payload": payload,
37
+ }
38
+ ],
39
+ )
40
+
41
+ def complete(self, *, output: dict[str, Any] | None = None, status: str = "COMPLETED") -> None:
42
+ if self._ended:
43
+ return
44
+ self._ended = True
45
+ span: dict[str, Any] = {
46
+ "id": self._span_id,
47
+ "execution_id": self._execution_id,
48
+ "span_type": self._span_type,
49
+ "name": self._name,
50
+ "status": status,
51
+ "started_at": self._started.isoformat(),
52
+ "ended_at": _utcnow().isoformat(),
53
+ }
54
+ if output:
55
+ span["metadata"] = {"output": str(output)}
56
+ self._tel.ingest_spans(self._execution_id, [span])
57
+
58
+ def __enter__(self) -> SpanContext:
59
+ self._tel.ingest_spans(
60
+ self._execution_id,
61
+ [
62
+ {
63
+ "id": self._span_id,
64
+ "execution_id": self._execution_id,
65
+ "span_type": self._span_type,
66
+ "name": self._name,
67
+ "status": "RUNNING",
68
+ "started_at": self._started.isoformat(),
69
+ }
70
+ ],
71
+ )
72
+ return self
73
+
74
+ def __exit__(self, exc_type, exc, tb) -> None:
75
+ if not self._ended:
76
+ status = "FAILED" if exc_type else "COMPLETED"
77
+ self.complete(status=status)
78
+
79
+
80
+ class ExecutionContext:
81
+ def __init__(self, tel: TelemetryClient, **kwargs: Any):
82
+ self._tel = tel
83
+ self._kwargs = kwargs
84
+ self._execution: dict[str, Any] | None = None
85
+
86
+ @property
87
+ def id(self) -> str:
88
+ if not self._execution:
89
+ raise RuntimeError("execution not started")
90
+ return self._execution["id"]
91
+
92
+ def span(self, name: str, *, span_type: str = "CUSTOM") -> SpanContext:
93
+ return SpanContext(self._tel, self.id, name, span_type)
94
+
95
+ def __enter__(self) -> ExecutionContext:
96
+ self._execution = self._tel.start_execution(**self._kwargs)
97
+ return self
98
+
99
+ def __exit__(self, exc_type, exc, tb) -> None:
100
+ if self._execution:
101
+ status = "FAILED" if exc_type else "COMPLETED"
102
+ self._tel.end_execution(self._execution["id"], status)
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.4
2
+ Name: fact0-sdk
3
+ Version: 1.0.0
4
+ Summary: Fact0 SDK - audit log and execution telemetry for AI agents
5
+ Author: Fact0
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://fact0.io
8
+ Project-URL: Documentation, https://docs.fact0.io
9
+ Project-URL: Repository, https://github.com/fact0-ai/fact0
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: pydantic<3,>=2.6
13
+ Requires-Dist: requests<3,>=2.31
14
+ Requires-Dist: httpx<1,>=0.27
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest>=8.0; extra == "dev"
17
+ Requires-Dist: pytest-mock>=3.12; extra == "dev"
18
+ Requires-Dist: mypy>=1.8; extra == "dev"
19
+ Provides-Extra: langchain
20
+ Requires-Dist: langchain-core>=0.2; extra == "langchain"
21
+ Provides-Extra: fastapi
22
+ Requires-Dist: fastapi>=0.110; extra == "fastapi"
23
+ Requires-Dist: starlette>=0.36; extra == "fastapi"
24
+
25
+ # fact0
26
+
27
+ Python SDK for [Fact0](https://fact0.io) - tamper-evident audit logs and execution telemetry for AI agents.
28
+
29
+ ```bash
30
+ pip install fact0-sdk
31
+ ```
32
+
33
+ ```python
34
+ import fact0
35
+
36
+ client = fact0.Client(api_key="alk_live_...")
37
+
38
+ client.audit.log(
39
+ actor={"id": "user_123", "type": "human"},
40
+ action="document.delete",
41
+ resource={"id": "doc_456", "type": "document"},
42
+ outcome="success",
43
+ )
44
+
45
+ with client.telemetry.execution(agent_id="bot-1") as ex:
46
+ with ex.span("tool.search", span_type="TOOL_CALL") as span:
47
+ span.complete(output={"hits": 3})
48
+ ```
49
+
50
+ Docs: [docs.fact0.io](https://docs.fact0.io)
51
+
52
+ ## Legacy imports
53
+
54
+ ```python
55
+ import fact0 # deprecated shim → fact0
56
+ import auditlog # deprecated shim → fact0.audit
57
+ ```
58
+
59
+ Both emit `DeprecationWarning` on import.
@@ -0,0 +1,21 @@
1
+ auditlog/__init__.py,sha256=WzK9mg9iF5G6EjpSz5dfWlfeFCfs1a4QdMzgF_4B6kc,550
2
+ auditlog/client.py,sha256=pU271OxVjVkmn95s5iN3mbN_AgevbNPKbQkslUQb1KY,1553
3
+ fact0/__init__.py,sha256=aozmzvJn-bjP_ombgomYoPLTmJPanGam2ESWiOzDYOc,2784
4
+ fact0/_http.py,sha256=n859pBaFxluuiqxSwPi_JhQaoaNCwGf36Ea2zWp30uo,3951
5
+ fact0/exceptions.py,sha256=gQqotlJ81K_aItPFHiVghdivQgTf248QSo9dXuUh7Ao,516
6
+ fact0/py.typed,sha256=Enkyrr7t9bKZQk8ywVperZB_J3TbNaaRpp6qn8sI34s,9
7
+ fact0/audit/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ fact0/audit/async_client.py,sha256=lUslD98S1bUWhM6UbbvYVWN9v_XxLltNWetc_m7KwMI,4367
9
+ fact0/audit/client.py,sha256=-kMTyy7ZKCi5RXKW2x3E2RgoLCcvGMrjvTM7W4Ktl9E,8025
10
+ fact0/audit/models.py,sha256=ZpZjKHCl0kJYRrxbx2HVBzCzXNjh_26BLlEYQZ93m-U,1361
11
+ fact0/audit/transport.py,sha256=NnyEu1ekM55JUWtxZ5Zj507UNrEF8SvPTjCcqh28hzg,1075
12
+ fact0/integrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ fact0/integrations/fastapi.py,sha256=z3p5hWKfD9RiQF0Re8q-dPwfTAzwhaucf9O3tq2I5RU,1415
14
+ fact0/integrations/langchain.py,sha256=m6NozNymff-NaDTkwd70cC29D7_Sg9nSarqlCtlZwi0,3693
15
+ fact0/telemetry/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
+ fact0/telemetry/client.py,sha256=P0qJJW9pLG-Hnf45x2B3UhuWNHy8b42vs6SBq1W6Kwg,2659
17
+ fact0/telemetry/context.py,sha256=jFrkVTN64g9F7tXVAp1oXvWhQvaWxx9c2oMl30SVn0M,3292
18
+ fact0_sdk-1.0.0.dist-info/METADATA,sha256=Uc4ZTfFnixdPK-Lj_Iul6YJ6AD1ERlUjF4v56X0BJu8,1612
19
+ fact0_sdk-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
20
+ fact0_sdk-1.0.0.dist-info/top_level.txt,sha256=dQi_O4dq47qwK2ohMu5UyVz7arTzaGZG8P9D8nCkKBw,15
21
+ fact0_sdk-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ auditlog
2
+ fact0