fact0-sdk 1.0.0__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 (33) hide show
  1. fact0_sdk-1.0.0/PKG-INFO +59 -0
  2. fact0_sdk-1.0.0/README.md +35 -0
  3. fact0_sdk-1.0.0/auditlog/__init__.py +17 -0
  4. fact0_sdk-1.0.0/auditlog/client.py +53 -0
  5. fact0_sdk-1.0.0/fact0/__init__.py +101 -0
  6. fact0_sdk-1.0.0/fact0/_http.py +125 -0
  7. fact0_sdk-1.0.0/fact0/audit/__init__.py +0 -0
  8. fact0_sdk-1.0.0/fact0/audit/async_client.py +129 -0
  9. fact0_sdk-1.0.0/fact0/audit/client.py +220 -0
  10. fact0_sdk-1.0.0/fact0/audit/models.py +57 -0
  11. fact0_sdk-1.0.0/fact0/audit/transport.py +35 -0
  12. fact0_sdk-1.0.0/fact0/exceptions.py +23 -0
  13. fact0_sdk-1.0.0/fact0/integrations/__init__.py +0 -0
  14. fact0_sdk-1.0.0/fact0/integrations/fastapi.py +45 -0
  15. fact0_sdk-1.0.0/fact0/integrations/langchain.py +96 -0
  16. fact0_sdk-1.0.0/fact0/py.typed +1 -0
  17. fact0_sdk-1.0.0/fact0/telemetry/__init__.py +0 -0
  18. fact0_sdk-1.0.0/fact0/telemetry/client.py +78 -0
  19. fact0_sdk-1.0.0/fact0/telemetry/context.py +102 -0
  20. fact0_sdk-1.0.0/fact0_sdk.egg-info/PKG-INFO +59 -0
  21. fact0_sdk-1.0.0/fact0_sdk.egg-info/SOURCES.txt +31 -0
  22. fact0_sdk-1.0.0/fact0_sdk.egg-info/dependency_links.txt +1 -0
  23. fact0_sdk-1.0.0/fact0_sdk.egg-info/requires.txt +15 -0
  24. fact0_sdk-1.0.0/fact0_sdk.egg-info/top_level.txt +2 -0
  25. fact0_sdk-1.0.0/pyproject.toml +59 -0
  26. fact0_sdk-1.0.0/setup.cfg +4 -0
  27. fact0_sdk-1.0.0/tests/test_async_client.py +49 -0
  28. fact0_sdk-1.0.0/tests/test_async_features.py +59 -0
  29. fact0_sdk-1.0.0/tests/test_batching.py +88 -0
  30. fact0_sdk-1.0.0/tests/test_client.py +149 -0
  31. fact0_sdk-1.0.0/tests/test_http.py +59 -0
  32. fact0_sdk-1.0.0/tests/test_models.py +59 -0
  33. fact0_sdk-1.0.0/tests/test_telemetry.py +42 -0
@@ -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,35 @@
1
+ # fact0
2
+
3
+ Python SDK for [Fact0](https://fact0.io) - tamper-evident audit logs and execution telemetry for AI agents.
4
+
5
+ ```bash
6
+ pip install fact0-sdk
7
+ ```
8
+
9
+ ```python
10
+ import fact0
11
+
12
+ client = fact0.Client(api_key="alk_live_...")
13
+
14
+ client.audit.log(
15
+ actor={"id": "user_123", "type": "human"},
16
+ action="document.delete",
17
+ resource={"id": "doc_456", "type": "document"},
18
+ outcome="success",
19
+ )
20
+
21
+ with client.telemetry.execution(agent_id="bot-1") as ex:
22
+ with ex.span("tool.search", span_type="TOOL_CALL") as span:
23
+ span.complete(output={"hits": 3})
24
+ ```
25
+
26
+ Docs: [docs.fact0.io](https://docs.fact0.io)
27
+
28
+ ## Legacy imports
29
+
30
+ ```python
31
+ import fact0 # deprecated shim → fact0
32
+ import auditlog # deprecated shim → fact0.audit
33
+ ```
34
+
35
+ Both emit `DeprecationWarning` on import.
@@ -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"]
@@ -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"]
@@ -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()
@@ -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")