parapet 0.2.0__py3-none-win_amd64.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.
parapet/__init__.py ADDED
@@ -0,0 +1,156 @@
1
+ # Copyright 2026 The Parapet Project
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ # Parapet Python SDK — defined in M1.11
5
+ #
6
+ # Public API:
7
+ # parapet.init(config_path) — validate config, start engine sidecar, patch httpx
8
+ # parapet.session(...) — context manager that sets W3C Baggage for the scope
9
+ # parapet.shutdown() — stop owned engine if permitted by file-cleanup gate
10
+ # parapet.untrusted(content) — mark a string as untrusted content
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from contextlib import contextmanager
15
+ from pathlib import Path
16
+ from typing import Generator
17
+
18
+ from parapet.header import _active_baggage, build_baggage_header
19
+ from parapet.sidecar import EngineState, start_engine, stop_engine
20
+ from parapet.transport import patch_httpx
21
+ from parapet.trust import _ensure_registry, _restore_registry, _swap_registry
22
+ from typing import Iterable
23
+
24
+ __all__ = ["init", "session", "shutdown", "untrusted"]
25
+
26
+ logger = logging.getLogger("parapet")
27
+
28
+ _DEFAULT_PORT = 9800
29
+
30
+ # Module-level engine state — single instance shared across init/session.
31
+ _engine_state = EngineState()
32
+
33
+
34
+ class SessionContext:
35
+ """Holds the baggage string for the duration of a session scope."""
36
+
37
+ __slots__ = ("baggage",)
38
+
39
+ def __init__(self, baggage: str) -> None:
40
+ self.baggage = baggage
41
+
42
+
43
+ def init(
44
+ config_path: str,
45
+ *,
46
+ port: int = _DEFAULT_PORT,
47
+ engine_bin: str | None = None,
48
+ extra_hosts: Iterable[str] | None = None,
49
+ state_dir: str | Path | None = None,
50
+ engine_args: Iterable[str] | None = None,
51
+ ) -> None:
52
+ """Initialize the parapet SDK.
53
+
54
+ Validates that *config_path* exists, starts the engine sidecar (if not
55
+ already running), and patches httpx to route LLM requests through the
56
+ engine.
57
+
58
+ Args:
59
+ config_path: Path to the parapet.yaml configuration file.
60
+ port: Port for the engine to listen on (default 9800).
61
+ engine_bin: Explicit path to the ``parapet-engine`` binary.
62
+ Falls back to ``PARAPET_ENGINE_PATH`` env var, then ``PATH``.
63
+ extra_hosts: Additional LLM API hosts to intercept beyond the
64
+ built-in defaults (e.g., ``["api.together.xyz"]``).
65
+ state_dir: Optional private state directory for receipt evidence
66
+ and the ``parapet-control/v0`` channel.
67
+ engine_args: Optional additional CLI flags passed to ``parapet-engine``.
68
+
69
+ Raises:
70
+ FileNotFoundError: If *config_path* does not exist or the engine
71
+ binary cannot be found.
72
+ """
73
+ path = Path(config_path)
74
+ if not path.exists():
75
+ raise FileNotFoundError(
76
+ f"Config file not found: {config_path}"
77
+ )
78
+
79
+ hosts = frozenset(extra_hosts) if extra_hosts else None
80
+ resolved_state_dir = Path(state_dir) if state_dir is not None else None
81
+ extra_engine_args = list(engine_args) if engine_args is not None else None
82
+ start_engine(
83
+ config_path=config_path,
84
+ port=port,
85
+ engine_bin=engine_bin,
86
+ state=_engine_state,
87
+ state_dir=resolved_state_dir,
88
+ engine_args=extra_engine_args,
89
+ )
90
+ patch_httpx(port=port, extra_hosts=hosts)
91
+
92
+
93
+ def shutdown() -> bool:
94
+ """Stop the owned engine sidecar if permitted by the file-cleanup gate.
95
+
96
+ Returns:
97
+ ``True`` if an owned engine was stopped, ``False`` if the engine is
98
+ not owned or shutdown was refused by the cleanup gate.
99
+ """
100
+ return stop_engine(state=_engine_state)
101
+
102
+
103
+ @contextmanager
104
+ def session(
105
+ *,
106
+ user_id: str | None = None,
107
+ role: str | None = None,
108
+ ) -> Generator[SessionContext, None, None]:
109
+ """Set W3C Baggage headers for the duration of this scope.
110
+
111
+ Must be called after ``init()``.
112
+
113
+ Args:
114
+ user_id: Optional user identifier to include in baggage.
115
+ role: Optional role string to include in baggage.
116
+
117
+ Yields:
118
+ A ``SessionContext`` with the computed baggage string.
119
+
120
+ Raises:
121
+ RuntimeError: If ``init()`` has not been called.
122
+ """
123
+ if not _engine_state.initialized:
124
+ raise RuntimeError(
125
+ "parapet.session() called before init(). Call parapet.init() first."
126
+ )
127
+
128
+ baggage = build_baggage_header(user_id=user_id, role=role)
129
+ baggage_token = _active_baggage.set(baggage)
130
+ trust_token = _swap_registry()
131
+ try:
132
+ yield SessionContext(baggage=baggage)
133
+ finally:
134
+ _restore_registry(trust_token)
135
+ _active_baggage.reset(baggage_token)
136
+
137
+
138
+ def untrusted(content: str, source: str = "unknown") -> str:
139
+ """Mark a string as untrusted content.
140
+
141
+ Registers the string in the per-context trust registry so the SDK
142
+ can locate it in serialized requests and emit byte-range trust spans.
143
+
144
+ The string is returned unchanged for inline use::
145
+
146
+ prompt = f"Context: {parapet.untrusted(rag_snippet, source='rag')}"
147
+
148
+ Args:
149
+ content: The untrusted content string.
150
+ source: Provenance label (e.g., "rag", "user_input", "web_search").
151
+
152
+ Returns:
153
+ The content string, unchanged.
154
+ """
155
+ registry = _ensure_registry()
156
+ return registry.register(content, source=source)
parapet/header.py ADDED
@@ -0,0 +1,97 @@
1
+ # Copyright 2026 The Parapet Project
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Header serialization for Parapet SDK.
5
+
6
+ Includes:
7
+ - W3C Baggage header (``baggage: key1=value1,key2=value2``)
8
+ - X-Guard-Trust header (``inline:<base64-encoded JSON>``)
9
+ - Active baggage ContextVar for session-scoped propagation
10
+
11
+ Values are percent-encoded to handle special characters safely.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import base64
16
+ import json
17
+ import logging
18
+ from contextvars import ContextVar
19
+ from urllib.parse import quote
20
+
21
+ __all__ = ["build_baggage_header", "build_trust_header", "get_active_baggage"]
22
+
23
+ # Active baggage for the current session scope (set by session(), read by transport).
24
+ _active_baggage: ContextVar[str | None] = ContextVar(
25
+ "parapet_active_baggage", default=None
26
+ )
27
+
28
+
29
+ def get_active_baggage() -> str | None:
30
+ """Return the active baggage string for the current context, or None."""
31
+ return _active_baggage.get()
32
+
33
+ MAX_TRUST_HEADER_BYTES = 4096 # 4KB inline limit
34
+
35
+ # Characters that must be percent-encoded in baggage values.
36
+ # W3C Baggage uses a subset of RFC 3986 — commas, semicolons, equals,
37
+ # and spaces are delimiters and must not appear unescaped in values.
38
+ _SAFE_CHARS = "" # encode everything except unreserved chars
39
+
40
+
41
+ def _encode_value(value: str) -> str:
42
+ """Percent-encode a baggage value per W3C Baggage spec."""
43
+ return quote(value, safe=_SAFE_CHARS)
44
+
45
+
46
+ def build_baggage_header(
47
+ *,
48
+ user_id: str | None = None,
49
+ role: str | None = None,
50
+ ) -> str:
51
+ """Build a W3C Baggage header value from optional fields.
52
+
53
+ Args:
54
+ user_id: Optional user identifier.
55
+ role: Optional role string.
56
+
57
+ Returns:
58
+ Baggage header value string (e.g. ``user_id=u_1,role=admin``).
59
+ Returns empty string when no fields are provided.
60
+ """
61
+ parts: list[str] = []
62
+
63
+ if user_id is not None:
64
+ parts.append(f"user_id={_encode_value(user_id)}")
65
+
66
+ if role is not None:
67
+ parts.append(f"role={_encode_value(role)}")
68
+
69
+ return ",".join(parts)
70
+
71
+
72
+ def build_trust_header(spans: list) -> str | None:
73
+ """Serialize trust spans as X-Guard-Trust header value.
74
+
75
+ Format: ``inline:<base64-encoded JSON>``
76
+ JSON schema: ``[{"s": start, "e": end, "src": source}, ...]``
77
+
78
+ Returns None if no spans or if encoded value exceeds 4KB limit.
79
+ Compact keys (s/e/src) to minimize header size.
80
+ """
81
+ if not spans:
82
+ return None
83
+
84
+ compact = [{"s": s.start, "e": s.end, "src": s.source} for s in spans]
85
+ payload = json.dumps(compact, separators=(",", ":")).encode("utf-8")
86
+
87
+ if len(payload) > MAX_TRUST_HEADER_BYTES:
88
+ logging.getLogger("parapet.header").warning(
89
+ "X-Guard-Trust header exceeds %d byte limit (%d spans, %d bytes); dropping",
90
+ MAX_TRUST_HEADER_BYTES,
91
+ len(spans),
92
+ len(payload),
93
+ )
94
+ return None
95
+
96
+ encoded = base64.b64encode(payload).decode("ascii")
97
+ return f"inline:{encoded}"