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.
- fact0_sdk-1.0.0/PKG-INFO +59 -0
- fact0_sdk-1.0.0/README.md +35 -0
- fact0_sdk-1.0.0/auditlog/__init__.py +17 -0
- fact0_sdk-1.0.0/auditlog/client.py +53 -0
- fact0_sdk-1.0.0/fact0/__init__.py +101 -0
- fact0_sdk-1.0.0/fact0/_http.py +125 -0
- fact0_sdk-1.0.0/fact0/audit/__init__.py +0 -0
- fact0_sdk-1.0.0/fact0/audit/async_client.py +129 -0
- fact0_sdk-1.0.0/fact0/audit/client.py +220 -0
- fact0_sdk-1.0.0/fact0/audit/models.py +57 -0
- fact0_sdk-1.0.0/fact0/audit/transport.py +35 -0
- fact0_sdk-1.0.0/fact0/exceptions.py +23 -0
- fact0_sdk-1.0.0/fact0/integrations/__init__.py +0 -0
- fact0_sdk-1.0.0/fact0/integrations/fastapi.py +45 -0
- fact0_sdk-1.0.0/fact0/integrations/langchain.py +96 -0
- fact0_sdk-1.0.0/fact0/py.typed +1 -0
- fact0_sdk-1.0.0/fact0/telemetry/__init__.py +0 -0
- fact0_sdk-1.0.0/fact0/telemetry/client.py +78 -0
- fact0_sdk-1.0.0/fact0/telemetry/context.py +102 -0
- fact0_sdk-1.0.0/fact0_sdk.egg-info/PKG-INFO +59 -0
- fact0_sdk-1.0.0/fact0_sdk.egg-info/SOURCES.txt +31 -0
- fact0_sdk-1.0.0/fact0_sdk.egg-info/dependency_links.txt +1 -0
- fact0_sdk-1.0.0/fact0_sdk.egg-info/requires.txt +15 -0
- fact0_sdk-1.0.0/fact0_sdk.egg-info/top_level.txt +2 -0
- fact0_sdk-1.0.0/pyproject.toml +59 -0
- fact0_sdk-1.0.0/setup.cfg +4 -0
- fact0_sdk-1.0.0/tests/test_async_client.py +49 -0
- fact0_sdk-1.0.0/tests/test_async_features.py +59 -0
- fact0_sdk-1.0.0/tests/test_batching.py +88 -0
- fact0_sdk-1.0.0/tests/test_client.py +149 -0
- fact0_sdk-1.0.0/tests/test_http.py +59 -0
- fact0_sdk-1.0.0/tests/test_models.py +59 -0
- fact0_sdk-1.0.0/tests/test_telemetry.py +42 -0
fact0_sdk-1.0.0/PKG-INFO
ADDED
|
@@ -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")
|