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 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
@@ -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
+ )