boring-agent-memory 0.2.2__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.
- boring_agent_memory/__init__.py +12 -0
- boring_agent_memory/api.py +23 -0
- boring_agent_memory/benchmark.py +524 -0
- boring_agent_memory/canonical.py +73 -0
- boring_agent_memory/chunking.py +425 -0
- boring_agent_memory/cli.py +298 -0
- boring_agent_memory/config.py +162 -0
- boring_agent_memory/embeddings.py +253 -0
- boring_agent_memory/eval.py +271 -0
- boring_agent_memory/index.py +449 -0
- boring_agent_memory/ingest.py +175 -0
- boring_agent_memory/privacy.py +84 -0
- boring_agent_memory/query.py +233 -0
- boring_agent_memory/schema.py +212 -0
- boring_agent_memory/server.py +41 -0
- boring_agent_memory/snippets.py +26 -0
- boring_agent_memory-0.2.2.dist-info/METADATA +162 -0
- boring_agent_memory-0.2.2.dist-info/RECORD +22 -0
- boring_agent_memory-0.2.2.dist-info/WHEEL +5 -0
- boring_agent_memory-0.2.2.dist-info/entry_points.txt +2 -0
- boring_agent_memory-0.2.2.dist-info/licenses/LICENSE +22 -0
- boring_agent_memory-0.2.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sqlite3
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .canonical import list_canonical_sources, verify_canonical_source
|
|
11
|
+
from .config import load_config
|
|
12
|
+
from .eval import evaluate_gates, run_eval
|
|
13
|
+
from .index import build_index, update_index
|
|
14
|
+
from .query import query_memory
|
|
15
|
+
from .schema import DEFAULT_DB_PATH, connect, fts5_available, init_db, schema_version
|
|
16
|
+
from .server import serve_stdio
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main(argv: list[str] | None = None) -> int:
|
|
20
|
+
parser = argparse.ArgumentParser(prog="bam", description="Boring Agent Memory")
|
|
21
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
22
|
+
parser.add_argument("--db", default=None, help="SQLite database path")
|
|
23
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
24
|
+
|
|
25
|
+
init_parser = subparsers.add_parser("init", help="Initialize the local database")
|
|
26
|
+
init_parser.add_argument("--json", action="store_true")
|
|
27
|
+
|
|
28
|
+
build_parser = subparsers.add_parser("build", help="Build a local BM25 index")
|
|
29
|
+
build_parser.add_argument("--config", help="YAML, TOML, or JSON memory config")
|
|
30
|
+
build_parser.add_argument("--include", action="append", default=[], help="File or directory to index")
|
|
31
|
+
build_parser.add_argument("--exclude", action="append", default=[], help="Glob to exclude")
|
|
32
|
+
build_parser.add_argument("--workspace", default=None, help="Workspace root for relative includes")
|
|
33
|
+
build_parser.add_argument("--source-type", default=None)
|
|
34
|
+
build_parser.add_argument("--max-bytes", type=int, default=None)
|
|
35
|
+
build_parser.add_argument("--chunk-size", type=int, default=None)
|
|
36
|
+
build_parser.add_argument("--json", action="store_true")
|
|
37
|
+
|
|
38
|
+
update_parser = subparsers.add_parser("update", help="Incrementally update a local index")
|
|
39
|
+
update_parser.add_argument("--config", help="YAML, TOML, or JSON memory config")
|
|
40
|
+
update_parser.add_argument("--include", action="append", default=[], help="File or directory to index")
|
|
41
|
+
update_parser.add_argument("--exclude", action="append", default=[], help="Glob to exclude")
|
|
42
|
+
update_parser.add_argument("--workspace", default=None, help="Workspace root for relative includes")
|
|
43
|
+
update_parser.add_argument("--source-type", default=None)
|
|
44
|
+
update_parser.add_argument("--max-bytes", type=int, default=None)
|
|
45
|
+
update_parser.add_argument("--chunk-size", type=int, default=None)
|
|
46
|
+
update_parser.add_argument("--dry-run", action="store_true")
|
|
47
|
+
update_parser.add_argument("--json", action="store_true")
|
|
48
|
+
|
|
49
|
+
query_parser = subparsers.add_parser("query", help="Query the local memory index")
|
|
50
|
+
query_parser.add_argument("query")
|
|
51
|
+
query_parser.add_argument("--limit", type=int, default=5)
|
|
52
|
+
query_parser.add_argument("--source-type", default=None)
|
|
53
|
+
query_parser.add_argument("--workspace", default=None)
|
|
54
|
+
query_parser.add_argument("--json", action="store_true")
|
|
55
|
+
|
|
56
|
+
status_parser = subparsers.add_parser("status", help="Show index status")
|
|
57
|
+
status_parser.add_argument("--json", action="store_true")
|
|
58
|
+
|
|
59
|
+
inspect_parser = subparsers.add_parser("inspect", help="Inspect indexed canonical sources")
|
|
60
|
+
inspect_parser.add_argument("source_path", nargs="?", help="Canonical source path to verify")
|
|
61
|
+
inspect_parser.add_argument("--limit", type=int, default=50)
|
|
62
|
+
inspect_parser.add_argument("--json", action="store_true")
|
|
63
|
+
|
|
64
|
+
health_parser = subparsers.add_parser("health", help="Run local health checks")
|
|
65
|
+
health_parser.add_argument("--json", action="store_true")
|
|
66
|
+
|
|
67
|
+
eval_parser = subparsers.add_parser("eval", help="Run deterministic memory retrieval evals")
|
|
68
|
+
eval_parser.add_argument(
|
|
69
|
+
"--fixture",
|
|
70
|
+
"--fixtures",
|
|
71
|
+
dest="fixtures",
|
|
72
|
+
default="evals/fixtures",
|
|
73
|
+
help="Directory containing eval fixture files",
|
|
74
|
+
)
|
|
75
|
+
eval_parser.add_argument(
|
|
76
|
+
"--golden",
|
|
77
|
+
default="evals/golden.jsonl",
|
|
78
|
+
help="JSONL file with golden eval cases",
|
|
79
|
+
)
|
|
80
|
+
eval_parser.add_argument("--limit", type=int, default=3)
|
|
81
|
+
eval_parser.add_argument("--min-recall-at-1", type=float, default=None)
|
|
82
|
+
eval_parser.add_argument("--min-recall-at-3", type=float, default=None)
|
|
83
|
+
eval_parser.add_argument("--min-source-accuracy", type=float, default=None)
|
|
84
|
+
eval_parser.add_argument("--min-snippet-term-rate", type=float, default=None)
|
|
85
|
+
eval_parser.add_argument("--min-stale-detection-rate", type=float, default=None)
|
|
86
|
+
eval_parser.add_argument("--max-privacy-leaks", type=int, default=None)
|
|
87
|
+
eval_parser.add_argument("--json", action="store_true")
|
|
88
|
+
|
|
89
|
+
serve_parser = subparsers.add_parser("serve", help="Serve a tiny JSON-lines query interface")
|
|
90
|
+
serve_parser.add_argument("--stdio", action="store_true", help="Read JSON lines from stdin")
|
|
91
|
+
|
|
92
|
+
args = parser.parse_args(argv)
|
|
93
|
+
db_path = Path(args.db).expanduser() if args.db else DEFAULT_DB_PATH
|
|
94
|
+
|
|
95
|
+
if args.command == "init":
|
|
96
|
+
conn = connect(db_path)
|
|
97
|
+
init_db(conn)
|
|
98
|
+
ok = fts5_available(conn)
|
|
99
|
+
conn.close()
|
|
100
|
+
return emit({"database": db_path.as_posix(), "fts5": ok}, args.json)
|
|
101
|
+
|
|
102
|
+
if args.command in {"build", "update"}:
|
|
103
|
+
try:
|
|
104
|
+
config = load_config(args.config) if args.config else None
|
|
105
|
+
except (OSError, ValueError) as exc:
|
|
106
|
+
parser.error(str(exc))
|
|
107
|
+
db_path = Path(args.db).expanduser() if args.db else (config.index_path if config else DEFAULT_DB_PATH)
|
|
108
|
+
includes = [*(config.include if config else ()), *args.include]
|
|
109
|
+
excludes = [*(config.exclude if config else ()), *args.exclude]
|
|
110
|
+
workspace = args.workspace or (config.workspace if config else None)
|
|
111
|
+
source_type = args.source_type or (config.source_type if config else "file")
|
|
112
|
+
max_bytes = args.max_bytes if args.max_bytes is not None else (config.max_bytes if config else 512 * 1024)
|
|
113
|
+
chunk_size = args.chunk_size if args.chunk_size is not None else (config.chunk_size if config else 1600)
|
|
114
|
+
if not includes:
|
|
115
|
+
parser.error(f"{args.command} requires --include or --config with include paths")
|
|
116
|
+
stats: dict[str, object]
|
|
117
|
+
if args.command == "build":
|
|
118
|
+
try:
|
|
119
|
+
stats = dict(
|
|
120
|
+
build_index(
|
|
121
|
+
db_path=db_path,
|
|
122
|
+
includes=list(includes),
|
|
123
|
+
excludes=list(excludes),
|
|
124
|
+
workspace=workspace,
|
|
125
|
+
source_type=source_type,
|
|
126
|
+
max_bytes=max_bytes,
|
|
127
|
+
chunk_size=chunk_size,
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
except (OSError, ValueError, sqlite3.DatabaseError) as exc:
|
|
131
|
+
parser.error(str(exc))
|
|
132
|
+
else:
|
|
133
|
+
try:
|
|
134
|
+
stats = update_index(
|
|
135
|
+
db_path=db_path,
|
|
136
|
+
includes=list(includes),
|
|
137
|
+
excludes=list(excludes),
|
|
138
|
+
workspace=workspace,
|
|
139
|
+
source_type=source_type,
|
|
140
|
+
max_bytes=max_bytes,
|
|
141
|
+
chunk_size=chunk_size,
|
|
142
|
+
dry_run=args.dry_run,
|
|
143
|
+
)
|
|
144
|
+
except (OSError, ValueError, sqlite3.DatabaseError) as exc:
|
|
145
|
+
parser.error(str(exc))
|
|
146
|
+
return emit({"database": db_path.as_posix(), **stats}, args.json)
|
|
147
|
+
|
|
148
|
+
if args.command == "query":
|
|
149
|
+
try:
|
|
150
|
+
results = [
|
|
151
|
+
result.to_dict()
|
|
152
|
+
for result in query_memory(
|
|
153
|
+
db_path=db_path,
|
|
154
|
+
query=args.query,
|
|
155
|
+
limit=args.limit,
|
|
156
|
+
source_type=args.source_type,
|
|
157
|
+
workspace=args.workspace,
|
|
158
|
+
)
|
|
159
|
+
]
|
|
160
|
+
except FileNotFoundError as exc:
|
|
161
|
+
parser.error(str(exc))
|
|
162
|
+
if args.json:
|
|
163
|
+
print(json.dumps({"query": args.query, "results": results}, indent=2, ensure_ascii=False))
|
|
164
|
+
else:
|
|
165
|
+
print_results(results)
|
|
166
|
+
return 0
|
|
167
|
+
|
|
168
|
+
if args.command == "status":
|
|
169
|
+
return emit(status(db_path), args.json)
|
|
170
|
+
|
|
171
|
+
if args.command == "inspect":
|
|
172
|
+
try:
|
|
173
|
+
if args.source_path:
|
|
174
|
+
return emit(verify_canonical_source(db_path, args.source_path), args.json)
|
|
175
|
+
payload: dict[str, object] = {
|
|
176
|
+
"database": db_path.as_posix(),
|
|
177
|
+
"sources": list_canonical_sources(db_path, limit=args.limit),
|
|
178
|
+
}
|
|
179
|
+
except FileNotFoundError as exc:
|
|
180
|
+
parser.error(str(exc))
|
|
181
|
+
return emit(payload, args.json)
|
|
182
|
+
|
|
183
|
+
if args.command == "health":
|
|
184
|
+
health = status(db_path)
|
|
185
|
+
health["sqlite_fts5"] = _sqlite_fts5_health()
|
|
186
|
+
health["ok"] = health["sqlite_fts5"] and health["database_exists"]
|
|
187
|
+
return emit(health, args.json)
|
|
188
|
+
|
|
189
|
+
if args.command == "eval":
|
|
190
|
+
try:
|
|
191
|
+
report = run_eval(fixture_dir=args.fixtures, golden_path=args.golden, limit=args.limit)
|
|
192
|
+
except FileNotFoundError as exc:
|
|
193
|
+
parser.error(f"eval fixtures not found: {exc}; run from a repository checkout or pass --fixtures and --golden")
|
|
194
|
+
failures = evaluate_gates(
|
|
195
|
+
report,
|
|
196
|
+
min_recall_at_1=args.min_recall_at_1,
|
|
197
|
+
min_recall_at_3=args.min_recall_at_3,
|
|
198
|
+
min_source_accuracy=args.min_source_accuracy,
|
|
199
|
+
min_snippet_term_rate=args.min_snippet_term_rate,
|
|
200
|
+
min_stale_detection_rate=args.min_stale_detection_rate,
|
|
201
|
+
max_privacy_leaks=args.max_privacy_leaks,
|
|
202
|
+
)
|
|
203
|
+
report["passed"] = not failures
|
|
204
|
+
report["failures"] = failures
|
|
205
|
+
if args.json:
|
|
206
|
+
print(json.dumps(report, indent=2, ensure_ascii=False))
|
|
207
|
+
else:
|
|
208
|
+
print_eval_report(report)
|
|
209
|
+
return 0 if not failures else 1
|
|
210
|
+
|
|
211
|
+
if args.command == "serve":
|
|
212
|
+
if not args.stdio:
|
|
213
|
+
parser.error("serve currently requires --stdio")
|
|
214
|
+
return serve_stdio(db_path)
|
|
215
|
+
|
|
216
|
+
parser.error("unknown command")
|
|
217
|
+
return 2
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def status(db_path: Path) -> dict[str, object]:
|
|
221
|
+
if not db_path.exists():
|
|
222
|
+
return {
|
|
223
|
+
"database": db_path.as_posix(),
|
|
224
|
+
"database_exists": False,
|
|
225
|
+
"records": 0,
|
|
226
|
+
"documents": 0,
|
|
227
|
+
"chunks": 0,
|
|
228
|
+
"schema_version": None,
|
|
229
|
+
"config_fingerprint": None,
|
|
230
|
+
"source_types": {},
|
|
231
|
+
}
|
|
232
|
+
conn = connect(db_path)
|
|
233
|
+
init_db(conn)
|
|
234
|
+
documents = conn.execute("SELECT count(*) FROM documents").fetchone()[0]
|
|
235
|
+
chunks = conn.execute("SELECT count(*) FROM chunks").fetchone()[0]
|
|
236
|
+
rows = conn.execute(
|
|
237
|
+
"SELECT source_type, count(*) AS count FROM documents GROUP BY source_type ORDER BY source_type"
|
|
238
|
+
).fetchall()
|
|
239
|
+
metadata = conn.execute("SELECT config_fingerprint FROM index_metadata WHERE singleton = 1").fetchone()
|
|
240
|
+
version = schema_version(conn)
|
|
241
|
+
conn.close()
|
|
242
|
+
return {
|
|
243
|
+
"database": db_path.as_posix(),
|
|
244
|
+
"database_exists": True,
|
|
245
|
+
"records": documents,
|
|
246
|
+
"documents": documents,
|
|
247
|
+
"chunks": chunks,
|
|
248
|
+
"schema_version": version,
|
|
249
|
+
"config_fingerprint": metadata["config_fingerprint"] if metadata else None,
|
|
250
|
+
"source_types": {row["source_type"]: row["count"] for row in rows},
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _sqlite_fts5_health() -> bool:
|
|
255
|
+
conn = sqlite3.connect(":memory:")
|
|
256
|
+
try:
|
|
257
|
+
return fts5_available(conn)
|
|
258
|
+
finally:
|
|
259
|
+
conn.close()
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def emit(payload: Mapping[str, object], as_json: bool) -> int:
|
|
263
|
+
if as_json:
|
|
264
|
+
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
265
|
+
else:
|
|
266
|
+
for key, value in payload.items():
|
|
267
|
+
print(f"{key}: {value}")
|
|
268
|
+
return 0
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def print_results(results: list[dict[str, object]]) -> None:
|
|
272
|
+
for result in results:
|
|
273
|
+
print(f"{result['title']} score={result['score']}")
|
|
274
|
+
print(f" {result['source_path']}")
|
|
275
|
+
print(f" {result['snippet']}")
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def print_eval_report(report: dict[str, object]) -> None:
|
|
279
|
+
metrics = report["metrics"]
|
|
280
|
+
assert isinstance(metrics, dict)
|
|
281
|
+
print(f"cases: {metrics['cases']}")
|
|
282
|
+
print(f"recall_at_1: {metrics['recall_at_1']:.3f}")
|
|
283
|
+
print(f"recall_at_3: {metrics['recall_at_3']:.3f}")
|
|
284
|
+
print(f"mrr: {metrics['mrr']:.3f}")
|
|
285
|
+
print(f"source_accuracy: {metrics['source_accuracy']:.3f}")
|
|
286
|
+
print(f"snippet_term_rate: {metrics['snippet_term_rate']:.3f}")
|
|
287
|
+
print(f"privacy_leak_count: {metrics['privacy_leak_count']}")
|
|
288
|
+
print(f"stale_detection_rate: {metrics['stale_detection_rate']:.3f}")
|
|
289
|
+
failures = report.get("failures", [])
|
|
290
|
+
if failures:
|
|
291
|
+
print("failures:")
|
|
292
|
+
assert isinstance(failures, list)
|
|
293
|
+
for failure in failures:
|
|
294
|
+
print(f"- {failure}")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
if __name__ == "__main__":
|
|
298
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import tomllib
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .chunking import DEFAULT_CHUNK_SIZE
|
|
10
|
+
from .schema import DEFAULT_DB_PATH
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class MemoryConfig:
|
|
15
|
+
index_path: Path = DEFAULT_DB_PATH
|
|
16
|
+
include: tuple[str, ...] = ()
|
|
17
|
+
exclude: tuple[str, ...] = ()
|
|
18
|
+
workspace: Path | None = None
|
|
19
|
+
source_type: str = "file"
|
|
20
|
+
max_file_size_kb: int = 512
|
|
21
|
+
chunk_size: int = DEFAULT_CHUNK_SIZE
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def max_bytes(self) -> int:
|
|
25
|
+
return self.max_file_size_kb * 1024
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_config(path: Path | str) -> MemoryConfig:
|
|
29
|
+
config_path = Path(path).expanduser()
|
|
30
|
+
raw = _load_mapping(config_path)
|
|
31
|
+
base_dir = config_path.resolve().parent
|
|
32
|
+
privacy_value = raw.get("privacy")
|
|
33
|
+
privacy: dict[str, Any] = privacy_value if isinstance(privacy_value, dict) else {}
|
|
34
|
+
|
|
35
|
+
index_path = _path_value(raw.get("index_path", raw.get("database")), DEFAULT_DB_PATH, base_dir)
|
|
36
|
+
workspace = _optional_path_value(raw.get("workspace"), base_dir)
|
|
37
|
+
if "max_bytes" in raw:
|
|
38
|
+
max_file_size_kb = max(1, int(raw["max_bytes"]) // 1024)
|
|
39
|
+
else:
|
|
40
|
+
max_file_size_kb = int(privacy.get("max_file_size_kb", raw.get("max_file_size_kb", 512)))
|
|
41
|
+
|
|
42
|
+
return MemoryConfig(
|
|
43
|
+
index_path=index_path,
|
|
44
|
+
include=tuple(_list_value(raw.get("include"))),
|
|
45
|
+
exclude=tuple(_list_value(raw.get("exclude"))),
|
|
46
|
+
workspace=workspace,
|
|
47
|
+
source_type=str(raw.get("source_type", "file")),
|
|
48
|
+
max_file_size_kb=max_file_size_kb,
|
|
49
|
+
chunk_size=int(raw.get("chunk_size", DEFAULT_CHUNK_SIZE)),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _load_mapping(path: Path) -> dict[str, Any]:
|
|
54
|
+
text = path.read_text(encoding="utf-8")
|
|
55
|
+
suffix = path.suffix.lower()
|
|
56
|
+
if suffix == ".json":
|
|
57
|
+
data = json.loads(text)
|
|
58
|
+
elif suffix == ".toml":
|
|
59
|
+
data = tomllib.loads(text)
|
|
60
|
+
elif suffix in {".yaml", ".yml"}:
|
|
61
|
+
data = _parse_simple_yaml(text)
|
|
62
|
+
else:
|
|
63
|
+
raise ValueError(f"Unsupported config format: {path.suffix}")
|
|
64
|
+
if not isinstance(data, dict):
|
|
65
|
+
raise ValueError("Config root must be a mapping") # noqa: TRY004
|
|
66
|
+
return data
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _parse_simple_yaml(text: str) -> dict[str, Any]:
|
|
70
|
+
"""Parse the small YAML subset used by BAM examples without a dependency."""
|
|
71
|
+
data: dict[str, Any] = {}
|
|
72
|
+
current_key: str | None = None
|
|
73
|
+
|
|
74
|
+
for line_number, raw_line in enumerate(text.splitlines(), start=1):
|
|
75
|
+
line = _strip_comment(raw_line).rstrip()
|
|
76
|
+
if not line.strip():
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
stripped = line.strip()
|
|
80
|
+
if not line.startswith(" ") and ":" in stripped and not stripped.startswith("- "):
|
|
81
|
+
key, value = stripped.split(":", 1)
|
|
82
|
+
key = key.strip()
|
|
83
|
+
value = value.strip()
|
|
84
|
+
if value:
|
|
85
|
+
data[key] = _scalar(value)
|
|
86
|
+
current_key = None
|
|
87
|
+
else:
|
|
88
|
+
data[key] = {}
|
|
89
|
+
current_key = key
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
if stripped.startswith("- ") and current_key:
|
|
93
|
+
if data[current_key] == {}:
|
|
94
|
+
data[current_key] = []
|
|
95
|
+
if not isinstance(data[current_key], list):
|
|
96
|
+
raise ValueError(f"unsupported YAML on line {line_number}: {current_key} mixes keys and list items")
|
|
97
|
+
data[current_key].append(_scalar(stripped[2:].strip()))
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
if current_key and isinstance(data.get(current_key), dict) and ":" in stripped:
|
|
101
|
+
key, value = stripped.split(":", 1)
|
|
102
|
+
data[current_key][key.strip()] = _scalar(value.strip())
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
raise ValueError(f"unsupported YAML on line {line_number}: {stripped}")
|
|
106
|
+
|
|
107
|
+
return data
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _strip_comment(line: str) -> str:
|
|
111
|
+
quote = ""
|
|
112
|
+
for index, char in enumerate(line):
|
|
113
|
+
starts_token = index == 0 or line[index - 1].isspace()
|
|
114
|
+
if quote:
|
|
115
|
+
if char == quote:
|
|
116
|
+
quote = ""
|
|
117
|
+
elif char in {"'", '"'} and starts_token:
|
|
118
|
+
quote = char
|
|
119
|
+
elif char == "#" and starts_token:
|
|
120
|
+
return line[:index]
|
|
121
|
+
return line
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _scalar(value: str) -> Any:
|
|
125
|
+
value = value.strip()
|
|
126
|
+
if value[:1] in {"[", "{"}:
|
|
127
|
+
raise ValueError(f"unsupported YAML flow syntax: {value}; use a block list")
|
|
128
|
+
if value[:1] in {"'", '"'}:
|
|
129
|
+
inner = value[1:-1]
|
|
130
|
+
if len(value) < 2 or value[-1] != value[0] or "''" in inner or (value[0] == '"' and "\\" in inner):
|
|
131
|
+
raise ValueError(f"unsupported YAML quoting: {value}")
|
|
132
|
+
return inner
|
|
133
|
+
if value.lower() in {"true", "false"}:
|
|
134
|
+
return value.lower() == "true"
|
|
135
|
+
try:
|
|
136
|
+
return int(value)
|
|
137
|
+
except ValueError:
|
|
138
|
+
return value
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _path_value(value: Any, default: Path, base_dir: Path) -> Path:
|
|
142
|
+
if value is None:
|
|
143
|
+
return default
|
|
144
|
+
path = Path(str(value)).expanduser()
|
|
145
|
+
return path if path.is_absolute() else base_dir / path
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _optional_path_value(value: Any, base_dir: Path) -> Path | None:
|
|
149
|
+
if value in {None, ""}:
|
|
150
|
+
return None
|
|
151
|
+
path = Path(str(value)).expanduser()
|
|
152
|
+
return path if path.is_absolute() else base_dir / path
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _list_value(value: Any) -> list[str]:
|
|
156
|
+
if value is None:
|
|
157
|
+
return []
|
|
158
|
+
if isinstance(value, str):
|
|
159
|
+
return [value]
|
|
160
|
+
if isinstance(value, list):
|
|
161
|
+
return [str(item) for item in value]
|
|
162
|
+
raise ValueError("Expected a string or list")
|