runtime-labs-sdk 0.1.0__py3-none-any.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.
@@ -0,0 +1,6 @@
1
+ """Runtime Labs local Python SDK. Call init before constructing clients or graphs."""
2
+
3
+ from .propagation import continue_trace, inject_context
4
+ from .sdk import SDK, init
5
+
6
+ __all__ = ["SDK", "continue_trace", "init", "inject_context"]
@@ -0,0 +1,204 @@
1
+ """Conservative, immutable source indexes. This module never imports application code."""
2
+
3
+ import dis
4
+ import hashlib
5
+ import marshal
6
+ import sys
7
+ import warnings
8
+ from dataclasses import dataclass
9
+ from pathlib import Path, PurePosixPath
10
+ from threading import RLock
11
+ from types import CodeType, MappingProxyType
12
+
13
+ from deployment_contracts import DeploymentManifest
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class Entity:
18
+ node_id: str
19
+ kind: str
20
+ qualified_symbol: str
21
+ path: str
22
+ source_digest: str
23
+ start: tuple[int, int]
24
+ end: tuple[int, int]
25
+ provider: str | None
26
+ operation: str | None
27
+
28
+
29
+ def normalized_code(code):
30
+ """Keep constants, closures, flags and positions; only checkout filenames differ."""
31
+ return code.replace(
32
+ co_filename="<verified-source>",
33
+ co_consts=tuple(normalized_code(c) if isinstance(c, CodeType) else c
34
+ for c in code.co_consts),
35
+ )
36
+
37
+
38
+ def fingerprint(code):
39
+ return hashlib.sha256(marshal.dumps(normalized_code(code))).digest()
40
+
41
+
42
+ def codes(code):
43
+ yield code
44
+ for constant in code.co_consts:
45
+ if isinstance(constant, CodeType):
46
+ yield from codes(constant)
47
+
48
+
49
+ def call_position(frame):
50
+ # Include inline CACHE instructions: suspended sync calls and async SEND/RESUME
51
+ # positions then retain the exact UTF-8 byte range, even for same-line calls.
52
+ instructions = [i for i in dis.get_instructions(frame.f_code, show_caches=True)
53
+ if i.offset <= frame.f_lasti]
54
+ instruction = next((i for i in reversed(instructions) if i.opname in {
55
+ "CALL", "CALL_KW", "CALL_FUNCTION_EX",
56
+ }), None)
57
+ p = instruction.positions if instruction else None
58
+ if p and None not in (p.lineno, p.col_offset, p.end_lineno, p.end_col_offset):
59
+ return (p.lineno, p.col_offset), (p.end_lineno, p.end_col_offset)
60
+ return None
61
+
62
+
63
+ class SourceIndex:
64
+ def __init__(self, path, diagnose, *, manifest=None):
65
+ self._diagnose = diagnose
66
+ self._lock = RLock()
67
+ self._compiled = {} # Bounded by files from the manifest, not request count.
68
+ self._json = None
69
+ entities = ()
70
+ if path is None and manifest is None:
71
+ diagnose("manifest_missing")
72
+ else:
73
+ try:
74
+ manifest = (DeploymentManifest.model_validate(manifest.model_dump())
75
+ if manifest is not None else
76
+ DeploymentManifest.model_validate_json(path.read_bytes()))
77
+ self._json = manifest.model_dump_json()
78
+ entities = tuple(Entity(
79
+ e.node_id, e.kind, e.qualified_symbol, e.source.path, e.source.source_digest,
80
+ (e.source.location.start_line, e.source.location.start_col),
81
+ (e.source.location.end_line, e.source.location.end_col),
82
+ e.provider, e.operation,
83
+ ) for e in manifest.entities)
84
+ except Exception:
85
+ diagnose("manifest_invalid")
86
+ self.entities = entities
87
+ grouped = {}
88
+ for entity in entities:
89
+ key = (entity.kind, entity.qualified_symbol)
90
+ grouped.setdefault(key, []).append(entity)
91
+ self.by_symbol = MappingProxyType({key: tuple(value) for key, value in grouped.items()})
92
+ self.node_ids = frozenset(e.node_id for e in entities)
93
+
94
+ @property
95
+ def manifest(self):
96
+ """A detached validated copy; changes cannot affect the attribution index."""
97
+ return DeploymentManifest.model_validate_json(self._json) if self._json else None
98
+
99
+ def candidates(self, code, globals_, kind):
100
+ module = globals_.get("__name__", "")
101
+ qualname = code.co_qualname.replace(".<locals>.", ".")
102
+ suffix = "" if qualname == "<module>" else "." + qualname
103
+ if module == "__main__":
104
+ spec_name = getattr(globals_.get("__spec__"), "name", None)
105
+ if spec_name and spec_name != "__main__":
106
+ module = spec_name
107
+ else:
108
+ # A directly executed script has no import name. Derive it only
109
+ # from complete canonical paths in this manifest, then prove the
110
+ # actual path/digest/code below. Never search by basename.
111
+ return tuple(e for e in self.entities if e.kind == kind
112
+ and e.qualified_symbol == ".".join(
113
+ PurePosixPath(e.path).with_suffix("").parts
114
+ ).removesuffix(".__init__") + suffix)
115
+ return self.by_symbol.get((kind, module + suffix), ())
116
+
117
+ def _proof(self, code, globals_, entity):
118
+ actual = Path(code.co_filename).resolve()
119
+ module_file = globals_.get("__file__")
120
+ relative = PurePosixPath(entity.path)
121
+ # Both module identity and every manifest path component must agree. No
122
+ # basename search, checkout paths from scanner metadata, or sys.path guessing.
123
+ if (not module_file or actual != Path(module_file).resolve()
124
+ or actual.parts[-len(relative.parts):] != relative.parts):
125
+ self._diagnose("source_path_mismatch")
126
+ return False
127
+ try:
128
+ raw = actual.read_bytes()
129
+ if hashlib.sha256(raw).hexdigest() != entity.source_digest:
130
+ self._diagnose("source_digest_mismatch")
131
+ return False
132
+ with self._lock:
133
+ expected = self._compiled.get(entity.source_digest)
134
+ if expected is None:
135
+ # compile, never exec: compare loaded code to the verified bytes.
136
+ # Compiler warnings can echo source lines. Verification must
137
+ # never print source bodies or alter application warnings.
138
+ with warnings.catch_warnings(record=True):
139
+ warnings.simplefilter("ignore")
140
+ tree = compile(raw, str(actual), "exec", dont_inherit=True,
141
+ optimize=sys.flags.optimize)
142
+ expected = frozenset(fingerprint(c) for c in codes(tree))
143
+ self._compiled[entity.source_digest] = expected
144
+ if fingerprint(code) not in expected:
145
+ self._diagnose("loaded_code_mismatch")
146
+ return False
147
+ return True
148
+ except Exception:
149
+ self._diagnose("source_unavailable")
150
+ return False
151
+
152
+ def match(self, code, globals_, kind, position=None):
153
+ candidates = self.candidates(code, globals_, kind)
154
+ if kind == "ModelCallSite":
155
+ candidates = tuple(e for e in candidates if position == (e.start, e.end))
156
+ elif kind == "Workflow":
157
+ candidates = tuple(e for e in candidates if position is not None
158
+ and e.start <= position[0] < position[1] <= e.end)
159
+ else:
160
+ # Functions can have decorators before the def range.
161
+ candidates = tuple(e for e in candidates if e.start[0] <= code.co_firstlineno
162
+ <= e.end[0])
163
+ if len(candidates) != 1:
164
+ if self.entities:
165
+ self._diagnose("source_ambiguous" if candidates else "source_unmatched")
166
+ return None
167
+ entity = candidates[0]
168
+ return entity if self._proof(code, globals_, entity) else None
169
+
170
+ def frame(self, frame, kind):
171
+ if frame is None:
172
+ return None
173
+ return self.match(frame.f_code, frame.f_globals, kind, call_position(frame))
174
+
175
+ def call_site(self, frame, depth=32):
176
+ """The model call a provider request belongs to.
177
+
178
+ That is the innermost frame whose current call is a verified call site. A
179
+ wrapper that forwards its caller's prompt is not one, so the walk continues
180
+ to the caller that built it. Frames outside the manifest are skipped quietly;
181
+ only a request no frame accounts for is diagnosed.
182
+ """
183
+ current = frame
184
+ while current is not None and depth:
185
+ position = call_position(current)
186
+ candidates = tuple(
187
+ e for e in self.candidates(current.f_code, current.f_globals, "ModelCallSite")
188
+ if position == (e.start, e.end)
189
+ )
190
+ if len(candidates) > 1:
191
+ self._diagnose("source_ambiguous")
192
+ return None
193
+ if candidates:
194
+ entity = candidates[0]
195
+ return entity if self._proof(current.f_code, current.f_globals, entity) else None
196
+ current, depth = current.f_back, depth - 1
197
+ if frame is not None and self.entities:
198
+ self._diagnose("source_unmatched")
199
+ return None
200
+
201
+ def function(self, function, kind):
202
+ if not hasattr(function, "__code__"):
203
+ return None
204
+ return self.match(function.__code__, function.__globals__, kind)
@@ -0,0 +1,60 @@
1
+ """Fetch a validated manifest at initialization; never persist credentials or remote data."""
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from urllib.parse import urlsplit
6
+
7
+ import httpx
8
+ from deployment_contracts import DeploymentManifest
9
+ from repository_identity import git_identity, normalize_git_url
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class RuntimeManifest:
14
+ manifest: DeploymentManifest
15
+ ingestion_url: str
16
+
17
+
18
+ def _request_manifest(api_url, api_key, git_url, commit_sha):
19
+ with httpx.Client(timeout=5, follow_redirects=False, trust_env=False) as client:
20
+ response = client.post(
21
+ api_url.rstrip("/") + "/api/sdk/manifest",
22
+ json={"git_url": git_url, "commit_sha": commit_sha},
23
+ headers={"Authorization": f"Bearer {api_key}"},
24
+ )
25
+ response.raise_for_status()
26
+ return response.json()
27
+
28
+
29
+ def fetch_runtime_manifest(config, diagnose):
30
+ try:
31
+ remote, commit = git_identity(Path.cwd())
32
+ except Exception:
33
+ diagnose("repository_identity_unavailable")
34
+ return None
35
+ try:
36
+ body = _request_manifest(config.api_url, config.api_key, remote, commit)
37
+ if normalize_git_url(body["git_url"]) != remote:
38
+ raise ValueError("Repository identity mismatch")
39
+ manifest = DeploymentManifest.model_validate(body["manifest"])
40
+ if commit and len(manifest.commit_sha) == 40 and manifest.commit_sha != commit:
41
+ diagnose("manifest_revision_mismatch")
42
+ return None
43
+ # The API returns a relative ingestion route, never an arbitrary credential destination.
44
+ path = body["ingestion_path"]
45
+ if (
46
+ not isinstance(path, str)
47
+ or not path.startswith("/api/repositories/")
48
+ or ":" in path
49
+ or "?" in path
50
+ or "#" in path
51
+ or ".." in path
52
+ ):
53
+ raise ValueError("Invalid ingestion path")
54
+ base = config.api_url.rstrip("/")
55
+ if urlsplit(base).scheme not in {"http", "https"}:
56
+ raise ValueError("Invalid API URL")
57
+ return RuntimeManifest(manifest, base + path)
58
+ except Exception:
59
+ diagnose("manifest_fetch_failed")
60
+ return None
runtime_labs/config.py ADDED
@@ -0,0 +1,42 @@
1
+ """Read runtime API settings and explicit offline overrides without environment mutation."""
2
+
3
+ import os
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+
7
+ from dotenv import dotenv_values
8
+
9
+ from .correlation import allowlist
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class Configuration:
14
+ api_key: str = field(repr=False)
15
+ manifest_path: Path | None
16
+ capture_payloads: bool = True
17
+ api_url: str = "http://127.0.0.1:8000"
18
+ correlation_keys: tuple = field(default_factory=allowlist)
19
+
20
+
21
+ def configuration(api_key: str, capture_payloads: bool = True, api_url: str | None = None,
22
+ correlation_keys=None) -> Configuration:
23
+ cwd = Path.cwd()
24
+ env_file = next((p / ".env" for p in (cwd, *cwd.parents) if (p / ".env").is_file()), None)
25
+ # No interpolation: do not resolve or copy unrelated application/scanner secrets.
26
+ value = os.environ.get("RUNTIME_LABS_MANIFEST_PATH")
27
+ base = cwd
28
+ if value is None and env_file is not None:
29
+ value = dotenv_values(env_file, interpolate=False).get("RUNTIME_LABS_MANIFEST_PATH")
30
+ base = env_file.parent
31
+ path = Path(value).expanduser() if value else None
32
+ if path is not None:
33
+ path = (base / path).resolve()
34
+ settings = dotenv_values(env_file, interpolate=False) if env_file else {}
35
+ endpoint = api_url or os.environ.get("RUNTIME_LABS_API_URL") or settings.get("RUNTIME_LABS_API_URL") or "http://127.0.0.1:8000"
36
+ keys = allowlist(
37
+ correlation_keys,
38
+ os.environ.get("RUNTIME_LABS_CORRELATION_KEYS")
39
+ or settings.get("RUNTIME_LABS_CORRELATION_KEYS")
40
+ or "",
41
+ )
42
+ return Configuration(api_key, path, capture_payloads, endpoint.rstrip("/"), keys)
@@ -0,0 +1,64 @@
1
+ """Record framework correlation identifiers verbatim; meaning is assigned downstream.
2
+
3
+ The SDK never decides what an identifier is (a conversation, a session, a run).
4
+ Each framework adapter says where that framework keeps run-scoped identifiers, and
5
+ only allowlisted keys are copied, because applications often keep user IDs and
6
+ secrets in the same configuration. Values become span attributes named
7
+ ``runtime_labs.ctx.<framework>.<key>``; Runtime Labs pipelines normalize them.
8
+
9
+ Extend the allowlist without code: ``RUNTIME_LABS_CORRELATION_KEYS=langgraph.tenant,
10
+ langgraph.session`` or ``init(correlation_keys={"langgraph": ["tenant"]})``.
11
+ """
12
+
13
+ import os
14
+ from collections.abc import Iterable, Mapping
15
+ from uuid import UUID
16
+
17
+ PREFIX = "runtime_labs.ctx."
18
+ MAX_VALUE_LENGTH = 256
19
+ DEFAULT_KEYS = {
20
+ # LangGraph keeps conversation and checkpoint identity in config["configurable"];
21
+ # run_id is a top-level RunnableConfig key.
22
+ "langgraph": ("thread_id", "checkpoint_id", "checkpoint_ns", "run_id"),
23
+ }
24
+
25
+
26
+ def allowlist(extra: Mapping[str, Iterable[str]] | None = None, environment=None):
27
+ """Frozen ``((framework, (keys...)), ...)`` so configurations stay comparable."""
28
+ merged = {framework: set(keys) for framework, keys in DEFAULT_KEYS.items()}
29
+ for framework, keys in (extra or {}).items():
30
+ merged.setdefault(framework, set()).update(keys)
31
+ for item in (environment if environment is not None else _environment()).split(","):
32
+ framework, _, key = item.strip().partition(".")
33
+ if framework and key:
34
+ merged.setdefault(framework, set()).add(key)
35
+ return tuple(sorted((framework, tuple(sorted(keys))) for framework, keys in merged.items()))
36
+
37
+
38
+ def _environment():
39
+ return os.environ.get("RUNTIME_LABS_CORRELATION_KEYS", "")
40
+
41
+
42
+ def capture(framework: str, values: Mapping, allowed) -> dict[str, str]:
43
+ """Copy allowlisted scalar identifiers; anything else is ignored, never stringified."""
44
+ keys = dict(allowed).get(framework, ())
45
+ attributes = {}
46
+ for key in keys:
47
+ value = values.get(key)
48
+ if isinstance(value, bool) or value is None:
49
+ continue
50
+ if isinstance(value, (str, int, UUID)):
51
+ text = str(value)
52
+ if text:
53
+ attributes[f"{PREFIX}{framework}.{key}"] = text[:MAX_VALUE_LENGTH]
54
+ return attributes
55
+
56
+
57
+ def langgraph(config, allowed) -> dict[str, str]:
58
+ if not isinstance(config, Mapping):
59
+ return {}
60
+ configurable = config.get("configurable")
61
+ values = dict(configurable) if isinstance(configurable, Mapping) else {}
62
+ if "run_id" in config:
63
+ values["run_id"] = config["run_id"]
64
+ return capture("langgraph", values, allowed)
runtime_labs/export.py ADDED
@@ -0,0 +1,109 @@
1
+ """Standard OTel exporters for local tracing and eval payloads."""
2
+
3
+ import json
4
+ import sys
5
+ from threading import RLock
6
+
7
+ from opentelemetry.sdk.trace import SpanProcessor
8
+ from opentelemetry.sdk.trace.export import SimpleSpanProcessor, SpanExporter, SpanExportResult
9
+ from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
10
+
11
+ SCOPE = "runtime_labs"
12
+
13
+
14
+ class LocalExporter(SpanExporter):
15
+ """One JSON object per finished SDK span; no resource/environment dump."""
16
+
17
+ def __init__(self, stream=None):
18
+ self.stream = stream if stream is not None else sys.stdout
19
+ self._lock = RLock()
20
+
21
+ def export(self, spans):
22
+ with self._lock:
23
+ for span in spans:
24
+ value = {
25
+ "name": span.name,
26
+ "trace_id": format(span.context.trace_id, "032x"),
27
+ "span_id": format(span.context.span_id, "016x"),
28
+ "parent_span_id": format(span.parent.span_id, "016x") if span.parent else None,
29
+ "attributes": dict(span.attributes or {}),
30
+ "status": span.status.status_code.name,
31
+ "start_time_ns": span.start_time,
32
+ "end_time_ns": span.end_time,
33
+ "duration_ns": span.end_time - span.start_time,
34
+ }
35
+ self.stream.write(json.dumps(value) + "\n")
36
+ self.stream.flush()
37
+ return SpanExportResult.SUCCESS
38
+
39
+ def shutdown(self):
40
+ pass
41
+
42
+ def force_flush(self, timeout_millis=30000):
43
+ self.stream.flush()
44
+ return True
45
+
46
+
47
+ class GuardedExporter(SpanExporter):
48
+ def __init__(self, exporter, diagnose):
49
+ self.exporter = exporter
50
+ self.diagnose = diagnose
51
+
52
+ def export(self, spans):
53
+ try:
54
+ result = self.exporter.export(spans)
55
+ if result != SpanExportResult.SUCCESS:
56
+ self.diagnose("export_failed")
57
+ return result
58
+ except Exception:
59
+ self.diagnose("export_failed")
60
+ return SpanExportResult.FAILURE
61
+
62
+ def force_flush(self, timeout_millis=30000):
63
+ try:
64
+ return self.exporter.force_flush(timeout_millis=timeout_millis)
65
+ except Exception:
66
+ self.diagnose("export_flush_failed")
67
+ return False
68
+
69
+ def shutdown(self):
70
+ try:
71
+ self.exporter.shutdown()
72
+ except Exception:
73
+ self.diagnose("export_shutdown_failed")
74
+
75
+
76
+ class ScopedProcessor(SpanProcessor):
77
+ """Reusable attachment on application providers; never exports their other scopes."""
78
+
79
+ def __init__(self):
80
+ self.delegate = None
81
+ self.exporter = None
82
+ self._lock = RLock()
83
+
84
+ def configure(self, exporter, diagnose):
85
+ with self._lock:
86
+ self.exporter = GuardedExporter(exporter, diagnose)
87
+ self.delegate = SimpleSpanProcessor(self.exporter)
88
+
89
+ def on_start(self, span, parent_context=None):
90
+ pass
91
+
92
+ def on_end(self, span):
93
+ with self._lock:
94
+ if self.delegate and span.instrumentation_scope.name == SCOPE:
95
+ self.delegate.on_end(span)
96
+
97
+ def force_flush(self, timeout_millis=30000):
98
+ with self._lock:
99
+ return self.exporter.force_flush(timeout_millis) if self.exporter else True
100
+
101
+ def shutdown(self):
102
+ with self._lock:
103
+ delegate, self.delegate = self.delegate, None
104
+ self.exporter = None
105
+ if delegate:
106
+ delegate.shutdown()
107
+
108
+
109
+ __all__ = ["InMemorySpanExporter", "LocalExporter"]