Skip to content

Commit 0a9fd42

Browse files
authored
Merge pull request #51 from IntentProof/python-sdk-core
Add Python SDK core with wrap and signed outbox
2 parents 41a989e + c0cf402 commit 0a9fd42

18 files changed

Lines changed: 1212 additions & 9 deletions

.github/workflows/ci.yml

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,7 @@ jobs:
1919

2020
- name: Install dependencies
2121
run: |
22-
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
23-
if [ -f pyproject.toml ]; then pip install -e .; fi
22+
pip install -e ".[dev]"
2423
2524
- name: Run tests
26-
run: |
27-
if [ -z "$(find tests -type f -name '*.py' 2>/dev/null)" ]; then
28-
echo "No test files found (project scaffold only)"
29-
exit 0
30-
fi
31-
python -m unittest discover -s tests -v
25+
run: pytest -q

pyproject.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@ description = "Python SDK for IntentProof"
99
readme = "README.md"
1010
license = {text = "Apache-2.0"}
1111
requires-python = ">=3.9"
12+
dependencies = [
13+
"cryptography>=42.0.0",
14+
"ulid-py>=1.1.0",
15+
]
16+
17+
[project.optional-dependencies]
18+
dev = [
19+
"pytest>=8.0.0",
20+
]
1221

1322
[tool.setuptools.packages.find]
1423
where = ["src"]

src/intentproof/__init__.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,23 @@
11
"""IntentProof Python SDK."""
22

3+
from intentproof import client
34
from intentproof.exporter import ingest_request_headers, post_execution_event
5+
from intentproof.instrumentation import (
6+
push_subject_mapping,
7+
run_with_correlation_id,
8+
wrap,
9+
)
10+
from intentproof.client import flush
411

5-
__all__ = ["ingest_request_headers", "post_execution_event"]
12+
configure = client.configure
13+
14+
__all__ = [
15+
"client",
16+
"configure",
17+
"flush",
18+
"ingest_request_headers",
19+
"post_execution_event",
20+
"push_subject_mapping",
21+
"run_with_correlation_id",
22+
"wrap",
23+
]

src/intentproof/client.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""SDK configuration and shared runtime state."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
from pathlib import Path
7+
from typing import TYPE_CHECKING
8+
9+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
10+
11+
from intentproof.http_exporter import HttpExporter, resolve_ingest_url
12+
from intentproof.keys import ensure_dir, load_or_create_keypair
13+
from intentproof.outbox import Outbox
14+
from intentproof.signing import load_private_key
15+
16+
if TYPE_CHECKING:
17+
from intentproof.signing import Ed25519PublicKey
18+
19+
SDK_VERSION = "python@0.1.0"
20+
21+
22+
def default_data_dir() -> Path:
23+
"""Default SDK data directory (resolved lazily for container imports)."""
24+
return Path.home() / ".intentproof" / "sdk-python"
25+
26+
_instance_private_key: Ed25519PrivateKey | None = None
27+
_instance_id: str | None = None
28+
_tenant_id: str = "tnt_default"
29+
_outbox: Outbox | None = None
30+
_exporter: HttpExporter | None = None
31+
_data_dir: Path | None = None
32+
33+
34+
def configure(
35+
*,
36+
db_path: str | None = None,
37+
tenant_id: str | None = None,
38+
data_dir: str | Path | None = None,
39+
ingest_url: str | None = None,
40+
) -> None:
41+
global _instance_private_key, _instance_id, _tenant_id, _outbox, _exporter, _data_dir
42+
43+
prev_exporter = _exporter
44+
prev_outbox = _outbox
45+
46+
new_data_dir = Path(data_dir) if data_dir else default_data_dir()
47+
ensure_dir(new_data_dir)
48+
49+
kp = load_or_create_keypair(new_data_dir)
50+
new_private_key = load_private_key(kp.private_key)
51+
new_instance_id = kp.instance_id
52+
new_tenant_id = (
53+
tenant_id
54+
or os.environ.get("INTENTPROOF_TENANT_ID", "").strip()
55+
or "tnt_default"
56+
)
57+
58+
resolved_db = db_path or os.environ.get("INTENTPROOF_OUTBOX_PATH", "").strip()
59+
if not resolved_db:
60+
resolved_db = str(new_data_dir / "outbox.db")
61+
new_outbox = Outbox(resolved_db)
62+
63+
ingest = resolve_ingest_url(ingest_url)
64+
new_exporter = HttpExporter(ingest) if ingest else None
65+
66+
if prev_exporter is not None:
67+
prev_exporter.flush()
68+
if prev_outbox is not None:
69+
prev_outbox.close()
70+
71+
_data_dir = new_data_dir
72+
_instance_private_key = new_private_key
73+
_instance_id = new_instance_id
74+
_tenant_id = new_tenant_id
75+
_outbox = new_outbox
76+
_exporter = new_exporter
77+
78+
79+
def flush() -> None:
80+
if _exporter is not None:
81+
_exporter.flush()
82+
83+
84+
def get_outbox() -> Outbox:
85+
if _outbox is None:
86+
raise RuntimeError("SDK not configured: call configure() before use")
87+
return _outbox
88+
89+
90+
def get_instance_id() -> str:
91+
if _instance_id is None:
92+
raise RuntimeError("SDK not configured: call configure() before get_instance_id()")
93+
return _instance_id
94+
95+
96+
def get_private_key() -> Ed25519PrivateKey:
97+
if _instance_private_key is None:
98+
raise RuntimeError("SDK not configured: call configure() before signing")
99+
return _instance_private_key
100+
101+
102+
def get_tenant_id() -> str:
103+
return _tenant_id
104+
105+
106+
def get_exporter() -> HttpExporter | None:
107+
return _exporter
108+
109+
110+
def get_public_key() -> "Ed25519PublicKey":
111+
return get_private_key().public_key()

src/intentproof/http_exporter.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""Background HTTP export of signed events to ingest."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
import os
7+
import threading
8+
from typing import Any, Mapping
9+
10+
from intentproof.exporter import ingest_request_headers, post_execution_event
11+
12+
logger = logging.getLogger(__name__)
13+
14+
DEFAULT_LOCAL_INGEST_URL = "http://127.0.0.1:9787/v1/events"
15+
16+
17+
def resolve_ingest_url(explicit: str | None = None) -> str | None:
18+
raw = (explicit or os.environ.get("INTENTPROOF_INGEST_URL", "")).strip()
19+
if raw:
20+
return _normalize_ingest_url(raw)
21+
if os.environ.get("INTENTPROOF_USE_LOCAL_INGEST", "").strip() == "1":
22+
return DEFAULT_LOCAL_INGEST_URL
23+
return None
24+
25+
26+
def _normalize_ingest_url(raw: str) -> str:
27+
trimmed = raw.strip().rstrip("/")
28+
if trimmed.endswith("/v1/events"):
29+
return trimmed
30+
return f"{trimmed}/v1/events"
31+
32+
33+
class HttpExporter:
34+
def __init__(self, ingest_url: str) -> None:
35+
self._ingest_url = ingest_url
36+
self._lock = threading.Lock()
37+
self._pending: list[threading.Thread] = []
38+
39+
@property
40+
def ingest_url(self) -> str:
41+
return self._ingest_url
42+
43+
def _prune_finished_threads(self) -> None:
44+
self._pending = [t for t in self._pending if t.is_alive()]
45+
46+
def enqueue(self, event: Mapping[str, Any]) -> None:
47+
thread = threading.Thread(
48+
target=self._export_one,
49+
args=(dict(event),),
50+
daemon=True,
51+
)
52+
with self._lock:
53+
self._prune_finished_threads()
54+
self._pending.append(thread)
55+
thread.start()
56+
57+
def _export_one(self, event: dict[str, Any]) -> None:
58+
try:
59+
post_execution_event(self._ingest_url, event)
60+
except Exception as exc:
61+
logger.warning("[intentproof] ingest export failed: %s", exc)
62+
63+
def flush(self) -> None:
64+
with self._lock:
65+
threads = list(self._pending)
66+
self._pending.clear()
67+
for thread in threads:
68+
if thread.is_alive():
69+
thread.join()

0 commit comments

Comments
 (0)