withlore 0.2.0a14__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.
- withlore/__init__.py +67 -0
- withlore/_capture.py +110 -0
- withlore/_capture_io.py +140 -0
- withlore/_config.py +80 -0
- withlore/_evals.py +640 -0
- withlore/_exporter.py +227 -0
- withlore/_instrumentation.py +230 -0
- withlore/_runtime.py +367 -0
- withlore/_terminal.py +188 -0
- withlore/_transport.py +287 -0
- withlore/_version.py +3 -0
- withlore/capture.py +1592 -0
- withlore/capture_adapters.py +505 -0
- withlore/capture_contract.py +13 -0
- withlore/cli.py +291 -0
- withlore/client.py +596 -0
- withlore/oauth.py +319 -0
- withlore/py.typed +1 -0
- withlore/qa.py +446 -0
- withlore-0.2.0a14.dist-info/METADATA +697 -0
- withlore-0.2.0a14.dist-info/RECORD +24 -0
- withlore-0.2.0a14.dist-info/WHEEL +4 -0
- withlore-0.2.0a14.dist-info/entry_points.txt +2 -0
- withlore-0.2.0a14.dist-info/licenses/LICENSE +202 -0
withlore/__init__.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Lore tracing for Python AI applications."""
|
|
2
|
+
|
|
3
|
+
from . import qa as qa
|
|
4
|
+
from ._capture import DROP, CaptureMode
|
|
5
|
+
from ._config import LoreConfig
|
|
6
|
+
from ._evals import (
|
|
7
|
+
Case,
|
|
8
|
+
Dataset,
|
|
9
|
+
EvalItem,
|
|
10
|
+
EvalResult,
|
|
11
|
+
EvalScore,
|
|
12
|
+
Score,
|
|
13
|
+
ScorerContext,
|
|
14
|
+
evaluate,
|
|
15
|
+
evaluate_async,
|
|
16
|
+
exact_match,
|
|
17
|
+
llm_judge,
|
|
18
|
+
)
|
|
19
|
+
from ._runtime import (
|
|
20
|
+
diagnostics,
|
|
21
|
+
flush,
|
|
22
|
+
init,
|
|
23
|
+
instrument,
|
|
24
|
+
shutdown,
|
|
25
|
+
span,
|
|
26
|
+
trace,
|
|
27
|
+
)
|
|
28
|
+
from ._terminal import print_eval_report
|
|
29
|
+
from ._version import __version__ as __version__
|
|
30
|
+
from .client import Client, LoreError
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"AssetRef",
|
|
34
|
+
"CaptureClient",
|
|
35
|
+
"CapturePolicy",
|
|
36
|
+
"DeliveryReceipt",
|
|
37
|
+
"FileSink",
|
|
38
|
+
"Image",
|
|
39
|
+
"Text",
|
|
40
|
+
"DROP",
|
|
41
|
+
"CaptureMode",
|
|
42
|
+
"Client",
|
|
43
|
+
"LoreError",
|
|
44
|
+
"Case",
|
|
45
|
+
"Dataset",
|
|
46
|
+
"EvalItem",
|
|
47
|
+
"EvalResult",
|
|
48
|
+
"EvalScore",
|
|
49
|
+
"LoreConfig",
|
|
50
|
+
"Score",
|
|
51
|
+
"ScorerContext",
|
|
52
|
+
"diagnostics",
|
|
53
|
+
"evaluate",
|
|
54
|
+
"evaluate_async",
|
|
55
|
+
"exact_match",
|
|
56
|
+
"flush",
|
|
57
|
+
"init",
|
|
58
|
+
"instrument",
|
|
59
|
+
"llm_judge",
|
|
60
|
+
"print_eval_report",
|
|
61
|
+
"qa",
|
|
62
|
+
"shutdown",
|
|
63
|
+
"span",
|
|
64
|
+
"trace",
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
from .capture import AssetRef, CaptureClient, CapturePolicy, DeliveryReceipt, FileSink, Image, Text
|
withlore/_capture.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import enum
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CaptureMode(str, enum.Enum):
|
|
12
|
+
FULL = "full"
|
|
13
|
+
METADATA_ONLY = "metadata_only"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _DropValue:
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
DROP = _DropValue()
|
|
21
|
+
Redactor = Callable[[str, Any], Any | _DropValue]
|
|
22
|
+
|
|
23
|
+
_SECRET_KEY = re.compile(
|
|
24
|
+
r"(?:^|[._-])(?:api[-_]?key|authorization|cookie|credential|password|secret|"
|
|
25
|
+
r"access[-_]?token|refresh[-_]?token|id[-_]?token)(?:$|[._-])",
|
|
26
|
+
re.IGNORECASE,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _sanitize(value: Any, *, redactor: Redactor | None = None, key: str = "") -> Any:
|
|
31
|
+
"""Convert a value to JSON-safe data while removing obvious secrets."""
|
|
32
|
+
|
|
33
|
+
if value is DROP:
|
|
34
|
+
return DROP
|
|
35
|
+
|
|
36
|
+
if key and _SECRET_KEY.search(key):
|
|
37
|
+
return "[REDACTED]"
|
|
38
|
+
|
|
39
|
+
if redactor is not None:
|
|
40
|
+
value = redactor(key, value)
|
|
41
|
+
if value is DROP:
|
|
42
|
+
return DROP
|
|
43
|
+
|
|
44
|
+
if value is None or isinstance(value, (bool, int, float, str)):
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
if isinstance(value, bytes):
|
|
48
|
+
return {"type": "bytes", "size": len(value)}
|
|
49
|
+
|
|
50
|
+
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
|
51
|
+
value = dataclasses.asdict(value)
|
|
52
|
+
|
|
53
|
+
if isinstance(value, Mapping):
|
|
54
|
+
result: dict[str, Any] = {}
|
|
55
|
+
for raw_key, item in value.items():
|
|
56
|
+
item_key = str(raw_key)
|
|
57
|
+
safe_key = sanitize(item_key, redactor=redactor, key="attribute.key")
|
|
58
|
+
if not isinstance(safe_key, str):
|
|
59
|
+
continue
|
|
60
|
+
sanitized = sanitize(item, redactor=redactor, key=item_key)
|
|
61
|
+
if sanitized is not DROP:
|
|
62
|
+
result[safe_key] = sanitized
|
|
63
|
+
return result
|
|
64
|
+
|
|
65
|
+
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
|
66
|
+
return [
|
|
67
|
+
sanitized
|
|
68
|
+
for item in value
|
|
69
|
+
if (sanitized := sanitize(item, redactor=redactor, key=key)) is not DROP
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
if hasattr(value, "model_dump"):
|
|
73
|
+
return sanitize(value.model_dump(mode="json"), redactor=redactor, key=key)
|
|
74
|
+
|
|
75
|
+
if hasattr(value, "to_dict"):
|
|
76
|
+
return sanitize(value.to_dict(), redactor=redactor, key=key)
|
|
77
|
+
|
|
78
|
+
return sanitize(repr(value), redactor=redactor, key=key)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def sanitize(value: Any, *, redactor: Redactor | None = None, key: str = "") -> Any:
|
|
82
|
+
"""Fail closed when a redactor or custom value cannot be safely serialized.
|
|
83
|
+
|
|
84
|
+
Named secret fields are removed automatically. Detecting arbitrary sensitive
|
|
85
|
+
text requires an application redactor; no general PII detection is implied.
|
|
86
|
+
"""
|
|
87
|
+
try:
|
|
88
|
+
return _sanitize(value, redactor=redactor, key=key)
|
|
89
|
+
except Exception:
|
|
90
|
+
return DROP
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def encode(value: Any, *, redactor: Redactor | None = None, limit: int) -> tuple[str, bool]:
|
|
94
|
+
sanitized = sanitize(value, redactor=redactor)
|
|
95
|
+
if sanitized is DROP:
|
|
96
|
+
sanitized = "[REDACTED]"
|
|
97
|
+
encoded = json.dumps(sanitized, separators=(",", ":"), ensure_ascii=False, default=repr)
|
|
98
|
+
encoded_bytes = encoded.encode("utf-8", errors="replace")
|
|
99
|
+
encoded = encoded_bytes.decode("utf-8")
|
|
100
|
+
if len(encoded_bytes) <= limit:
|
|
101
|
+
return encoded, False
|
|
102
|
+
|
|
103
|
+
marker = "...[truncated]"
|
|
104
|
+
preview = encoded_bytes[:limit].decode("utf-8", errors="ignore")
|
|
105
|
+
while preview:
|
|
106
|
+
truncated_value = json.dumps(preview + marker, ensure_ascii=False)
|
|
107
|
+
if len(truncated_value.encode("utf-8")) <= limit:
|
|
108
|
+
return truncated_value, True
|
|
109
|
+
preview = preview[:-1]
|
|
110
|
+
return json.dumps(marker[: max(0, limit - 2)]), True
|
withlore/_capture_io.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Bounded ordered capture I/O. No backend or agent runtime dependencies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import queue
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Callable, Iterable
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, BinaryIO
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DurableIO:
|
|
15
|
+
"""One ordered worker; producer callbacks never wait on filesystem writes."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
background: bool = True,
|
|
20
|
+
maximum_bytes: int = 4_000_000,
|
|
21
|
+
maximum_operations: int = 4096,
|
|
22
|
+
close_timeout: float = 10.0,
|
|
23
|
+
) -> None:
|
|
24
|
+
if maximum_bytes < 1 or maximum_operations < 1 or close_timeout <= 0:
|
|
25
|
+
raise ValueError("Capture I/O limits must be positive")
|
|
26
|
+
self.background, self.maximum_bytes = background, maximum_bytes
|
|
27
|
+
self.close_timeout = close_timeout
|
|
28
|
+
self.pending_bytes = 0
|
|
29
|
+
self.failure: BaseException | None = None
|
|
30
|
+
self.lock = threading.RLock()
|
|
31
|
+
self.queue: queue.Queue[tuple[Callable[[], Any], int] | None] = queue.Queue(
|
|
32
|
+
maximum_operations
|
|
33
|
+
)
|
|
34
|
+
self.closed = False
|
|
35
|
+
self.thread = threading.Thread(target=self._consume, name="withlore-capture", daemon=True)
|
|
36
|
+
if background:
|
|
37
|
+
self.thread.start()
|
|
38
|
+
|
|
39
|
+
def check(self) -> None:
|
|
40
|
+
if self.failure is not None:
|
|
41
|
+
raise OSError("Capture persistence failed; evidence is incomplete") from None
|
|
42
|
+
|
|
43
|
+
def submit(self, action: Callable[[], Any], size: int = 0) -> None:
|
|
44
|
+
with self.lock:
|
|
45
|
+
self.check()
|
|
46
|
+
if self.closed:
|
|
47
|
+
raise OSError("Capture writer is closed")
|
|
48
|
+
if size < 0 or self.pending_bytes + size > self.maximum_bytes:
|
|
49
|
+
raise OSError("Capture backlog byte limit exceeded")
|
|
50
|
+
if not self.background:
|
|
51
|
+
action()
|
|
52
|
+
return
|
|
53
|
+
self.pending_bytes += size
|
|
54
|
+
try:
|
|
55
|
+
self.queue.put_nowait((action, size))
|
|
56
|
+
except queue.Full:
|
|
57
|
+
self.pending_bytes -= size
|
|
58
|
+
raise OSError("Capture backlog operation limit exceeded") from None
|
|
59
|
+
|
|
60
|
+
def _consume(self) -> None:
|
|
61
|
+
while True:
|
|
62
|
+
item = self.queue.get()
|
|
63
|
+
try:
|
|
64
|
+
if item is None:
|
|
65
|
+
return
|
|
66
|
+
action, size = item
|
|
67
|
+
try:
|
|
68
|
+
action()
|
|
69
|
+
except BaseException as error:
|
|
70
|
+
self.failure = error
|
|
71
|
+
finally:
|
|
72
|
+
with self.lock:
|
|
73
|
+
self.pending_bytes -= size
|
|
74
|
+
finally:
|
|
75
|
+
self.queue.task_done()
|
|
76
|
+
|
|
77
|
+
def write(self, stream: BinaryIO, data: bytes) -> None:
|
|
78
|
+
owned = bytes(data)
|
|
79
|
+
self.submit(lambda: stream.write(owned), len(owned))
|
|
80
|
+
|
|
81
|
+
def append(self, path: Path, data: bytes) -> None:
|
|
82
|
+
owned = bytes(data)
|
|
83
|
+
|
|
84
|
+
def action() -> None:
|
|
85
|
+
with path.open("ab") as stream:
|
|
86
|
+
stream.write(owned)
|
|
87
|
+
stream.flush()
|
|
88
|
+
os.fsync(stream.fileno())
|
|
89
|
+
|
|
90
|
+
self.submit(action, len(owned))
|
|
91
|
+
|
|
92
|
+
def flush(
|
|
93
|
+
self,
|
|
94
|
+
streams: Iterable[BinaryIO] = (),
|
|
95
|
+
*,
|
|
96
|
+
durable: bool = False,
|
|
97
|
+
checkpoint: Callable[[], None] = lambda: None,
|
|
98
|
+
) -> None:
|
|
99
|
+
handles = tuple(streams)
|
|
100
|
+
|
|
101
|
+
def action() -> None:
|
|
102
|
+
for stream in handles:
|
|
103
|
+
stream.flush()
|
|
104
|
+
if durable:
|
|
105
|
+
os.fsync(stream.fileno())
|
|
106
|
+
checkpoint()
|
|
107
|
+
|
|
108
|
+
self.submit(action)
|
|
109
|
+
|
|
110
|
+
def drain(self, timeout: float | None = None) -> None:
|
|
111
|
+
deadline = time.monotonic() + (self.close_timeout if timeout is None else timeout)
|
|
112
|
+
if self.background:
|
|
113
|
+
with self.queue.all_tasks_done:
|
|
114
|
+
while self.queue.unfinished_tasks:
|
|
115
|
+
remaining = deadline - time.monotonic()
|
|
116
|
+
if remaining <= 0:
|
|
117
|
+
raise TimeoutError("Capture drain deadline exceeded")
|
|
118
|
+
self.queue.all_tasks_done.wait(remaining)
|
|
119
|
+
self.check()
|
|
120
|
+
|
|
121
|
+
def close(self, timeout: float | None = None) -> None:
|
|
122
|
+
duration = self.close_timeout if timeout is None else timeout
|
|
123
|
+
deadline = time.monotonic() + duration
|
|
124
|
+
if self.closed:
|
|
125
|
+
self.check()
|
|
126
|
+
return
|
|
127
|
+
try:
|
|
128
|
+
self.drain(duration)
|
|
129
|
+
finally:
|
|
130
|
+
with self.lock:
|
|
131
|
+
self.closed = True
|
|
132
|
+
if self.background:
|
|
133
|
+
try:
|
|
134
|
+
self.queue.put(None, timeout=max(0, deadline - time.monotonic()))
|
|
135
|
+
except queue.Full:
|
|
136
|
+
raise TimeoutError("Capture close deadline exceeded") from None
|
|
137
|
+
self.thread.join(max(0, deadline - time.monotonic()))
|
|
138
|
+
if self.thread.is_alive():
|
|
139
|
+
raise TimeoutError("Capture close deadline exceeded")
|
|
140
|
+
self.check()
|
withlore/_config.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
from ._capture import CaptureMode, Redactor
|
|
9
|
+
|
|
10
|
+
DEFAULT_ENDPOINT = "https://lore-labs.ai/api/v1/traces"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _endpoint(value: str | None, project_id: str | None) -> str:
|
|
14
|
+
endpoint = (value or os.getenv("LORE_ENDPOINT") or DEFAULT_ENDPOINT).rstrip("/")
|
|
15
|
+
if project_id:
|
|
16
|
+
suffix = f"/v2/projects/{quote(project_id, safe='')}/traces"
|
|
17
|
+
if endpoint.endswith(suffix):
|
|
18
|
+
return endpoint
|
|
19
|
+
if endpoint.endswith("/v1/traces"):
|
|
20
|
+
endpoint = endpoint.removesuffix("/v1/traces")
|
|
21
|
+
return f"{endpoint}{suffix}"
|
|
22
|
+
if endpoint.endswith("/v1/traces"):
|
|
23
|
+
return endpoint
|
|
24
|
+
return f"{endpoint}/v1/traces"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class LoreConfig:
|
|
29
|
+
api_key: str = field(repr=False)
|
|
30
|
+
endpoint: str = DEFAULT_ENDPOINT
|
|
31
|
+
project_id: str | None = None
|
|
32
|
+
environment: str = "development"
|
|
33
|
+
capture_mode: CaptureMode = CaptureMode.METADATA_ONLY
|
|
34
|
+
redactor: Redactor | None = field(default=None, compare=False, repr=False)
|
|
35
|
+
instrumentations: tuple[str, ...] = ("auto",)
|
|
36
|
+
max_value_bytes: int = 256 * 1024
|
|
37
|
+
max_queue_size: int = 2_048
|
|
38
|
+
max_export_batch_size: int = 128
|
|
39
|
+
schedule_delay_millis: int = 5_000
|
|
40
|
+
export_timeout_millis: int = 10_000
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def load(
|
|
44
|
+
cls,
|
|
45
|
+
*,
|
|
46
|
+
api_key: str | None = None,
|
|
47
|
+
endpoint: str | None = None,
|
|
48
|
+
project_id: str | None = None,
|
|
49
|
+
environment: str | None = None,
|
|
50
|
+
capture_content: bool = False,
|
|
51
|
+
redactor: Redactor | None = None,
|
|
52
|
+
instrumentations: str | Iterable[str] = "auto",
|
|
53
|
+
) -> LoreConfig:
|
|
54
|
+
resolved_key = (api_key or os.getenv("LORE_API_KEY") or "").strip()
|
|
55
|
+
if not resolved_key:
|
|
56
|
+
raise ValueError("Lore API key is required; pass api_key or set LORE_API_KEY")
|
|
57
|
+
|
|
58
|
+
resolved_environment = (
|
|
59
|
+
environment or os.getenv("LORE_ENVIRONMENT") or "development"
|
|
60
|
+
).strip()
|
|
61
|
+
if not resolved_environment:
|
|
62
|
+
raise ValueError("Lore environment cannot be empty")
|
|
63
|
+
|
|
64
|
+
resolved_project_id = (project_id or os.getenv("LORE_PROJECT_ID") or "").strip() or None
|
|
65
|
+
if resolved_project_id and "/" in resolved_project_id:
|
|
66
|
+
raise ValueError("Lore project ID cannot contain a slash")
|
|
67
|
+
|
|
68
|
+
selected = (
|
|
69
|
+
(instrumentations,) if isinstance(instrumentations, str) else tuple(instrumentations)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
return cls(
|
|
73
|
+
api_key=resolved_key,
|
|
74
|
+
endpoint=_endpoint(endpoint, resolved_project_id),
|
|
75
|
+
project_id=resolved_project_id,
|
|
76
|
+
environment=resolved_environment,
|
|
77
|
+
capture_mode=CaptureMode.FULL if capture_content else CaptureMode.METADATA_ONLY,
|
|
78
|
+
redactor=redactor,
|
|
79
|
+
instrumentations=selected,
|
|
80
|
+
)
|