Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 2 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,7 @@ jobs:

- name: Install dependencies
run: |
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
if [ -f pyproject.toml ]; then pip install -e .; fi
pip install -e ".[dev]"

- name: Run tests
run: |
if [ -z "$(find tests -type f -name '*.py' 2>/dev/null)" ]; then
echo "No test files found (project scaffold only)"
exit 0
fi
python -m unittest discover -s tests -v
run: pytest -q
9 changes: 9 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ description = "Python SDK for IntentProof"
readme = "README.md"
license = {text = "Apache-2.0"}
requires-python = ">=3.9"
dependencies = [
"cryptography>=42.0.0",
"ulid-py>=1.1.0",
]

[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
]

[tool.setuptools.packages.find]
where = ["src"]
Expand Down
20 changes: 19 additions & 1 deletion src/intentproof/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
"""IntentProof Python SDK."""

from intentproof import client
from intentproof.exporter import ingest_request_headers, post_execution_event
from intentproof.instrumentation import (
push_subject_mapping,
run_with_correlation_id,
wrap,
)
from intentproof.client import flush

__all__ = ["ingest_request_headers", "post_execution_event"]
configure = client.configure

__all__ = [
"client",
"configure",
"flush",
"ingest_request_headers",
"post_execution_event",
"push_subject_mapping",
"run_with_correlation_id",
"wrap",
]
111 changes: 111 additions & 0 deletions src/intentproof/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"""SDK configuration and shared runtime state."""

from __future__ import annotations

import os
from pathlib import Path
from typing import TYPE_CHECKING

from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey

from intentproof.http_exporter import HttpExporter, resolve_ingest_url
from intentproof.keys import ensure_dir, load_or_create_keypair
from intentproof.outbox import Outbox
from intentproof.signing import load_private_key

if TYPE_CHECKING:
from intentproof.signing import Ed25519PublicKey

SDK_VERSION = "python@0.1.0"


def default_data_dir() -> Path:
"""Default SDK data directory (resolved lazily for container imports)."""
return Path.home() / ".intentproof" / "sdk-python"

_instance_private_key: Ed25519PrivateKey | None = None
_instance_id: str | None = None
_tenant_id: str = "tnt_default"
_outbox: Outbox | None = None
_exporter: HttpExporter | None = None
_data_dir: Path | None = None


def configure(
*,
db_path: str | None = None,
tenant_id: str | None = None,
data_dir: str | Path | None = None,
ingest_url: str | None = None,
) -> None:
global _instance_private_key, _instance_id, _tenant_id, _outbox, _exporter, _data_dir

prev_exporter = _exporter
prev_outbox = _outbox

new_data_dir = Path(data_dir) if data_dir else default_data_dir()
ensure_dir(new_data_dir)

kp = load_or_create_keypair(new_data_dir)
new_private_key = load_private_key(kp.private_key)
new_instance_id = kp.instance_id
new_tenant_id = (
tenant_id
or os.environ.get("INTENTPROOF_TENANT_ID", "").strip()
or "tnt_default"
)

resolved_db = db_path or os.environ.get("INTENTPROOF_OUTBOX_PATH", "").strip()
if not resolved_db:
resolved_db = str(new_data_dir / "outbox.db")
new_outbox = Outbox(resolved_db)

ingest = resolve_ingest_url(ingest_url)
new_exporter = HttpExporter(ingest) if ingest else None

if prev_exporter is not None:
prev_exporter.flush()
if prev_outbox is not None:
prev_outbox.close()

_data_dir = new_data_dir
_instance_private_key = new_private_key
_instance_id = new_instance_id
_tenant_id = new_tenant_id
_outbox = new_outbox
_exporter = new_exporter


def flush() -> None:
if _exporter is not None:
_exporter.flush()


def get_outbox() -> Outbox:
if _outbox is None:
raise RuntimeError("SDK not configured: call configure() before use")
return _outbox


def get_instance_id() -> str:
if _instance_id is None:
raise RuntimeError("SDK not configured: call configure() before get_instance_id()")
return _instance_id


def get_private_key() -> Ed25519PrivateKey:
if _instance_private_key is None:
raise RuntimeError("SDK not configured: call configure() before signing")
return _instance_private_key


def get_tenant_id() -> str:
return _tenant_id


def get_exporter() -> HttpExporter | None:
return _exporter


def get_public_key() -> "Ed25519PublicKey":
return get_private_key().public_key()
69 changes: 69 additions & 0 deletions src/intentproof/http_exporter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""Background HTTP export of signed events to ingest."""

from __future__ import annotations

import logging
import os
import threading
from typing import Any, Mapping

from intentproof.exporter import ingest_request_headers, post_execution_event

logger = logging.getLogger(__name__)

DEFAULT_LOCAL_INGEST_URL = "http://127.0.0.1:9787/v1/events"


def resolve_ingest_url(explicit: str | None = None) -> str | None:
raw = (explicit or os.environ.get("INTENTPROOF_INGEST_URL", "")).strip()
if raw:
return _normalize_ingest_url(raw)
if os.environ.get("INTENTPROOF_USE_LOCAL_INGEST", "").strip() == "1":
return DEFAULT_LOCAL_INGEST_URL
return None


def _normalize_ingest_url(raw: str) -> str:
trimmed = raw.strip().rstrip("/")
if trimmed.endswith("/v1/events"):
return trimmed
return f"{trimmed}/v1/events"


class HttpExporter:
def __init__(self, ingest_url: str) -> None:
self._ingest_url = ingest_url
self._lock = threading.Lock()
self._pending: list[threading.Thread] = []

@property
def ingest_url(self) -> str:
return self._ingest_url

def _prune_finished_threads(self) -> None:
self._pending = [t for t in self._pending if t.is_alive()]

def enqueue(self, event: Mapping[str, Any]) -> None:
thread = threading.Thread(
target=self._export_one,
args=(dict(event),),
daemon=True,
)
with self._lock:
self._prune_finished_threads()
self._pending.append(thread)
thread.start()
Comment thread
cursor[bot] marked this conversation as resolved.

def _export_one(self, event: dict[str, Any]) -> None:
try:
post_execution_event(self._ingest_url, event)
except Exception as exc:
logger.warning("[intentproof] ingest export failed: %s", exc)

def flush(self) -> None:
with self._lock:
threads = list(self._pending)
self._pending.clear()
for thread in threads:
if thread.is_alive():
thread.join()
Loading
Loading