arkiv 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.
arkiv/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """arkiv: Universal personal data format."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .record import Record, parse_record, parse_jsonl
6
+ from .schema import (
7
+ SchemaEntry,
8
+ CollectionSchema,
9
+ discover_schema,
10
+ load_schema_yaml,
11
+ save_schema_yaml,
12
+ )
13
+ from .readme import Readme, parse_readme, save_readme
14
+ from .database import Database
15
+
16
+ __all__ = [
17
+ "Record",
18
+ "parse_record",
19
+ "parse_jsonl",
20
+ "SchemaEntry",
21
+ "CollectionSchema",
22
+ "discover_schema",
23
+ "load_schema_yaml",
24
+ "save_schema_yaml",
25
+ "Readme",
26
+ "parse_readme",
27
+ "save_readme",
28
+ "Database",
29
+ ]
arkiv/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow running arkiv as a module: python -m arkiv.cli"""
2
+
3
+ from arkiv.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
arkiv/cli.py ADDED
@@ -0,0 +1,393 @@
1
+ """arkiv CLI."""
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from . import __version__
9
+
10
+
11
+ def cmd_import(args):
12
+ """Import JSONL, README.md, or directory into a SQLite database."""
13
+ from .database import Database
14
+
15
+ input_path = Path(args.input)
16
+
17
+ if input_path.suffix == ".db":
18
+ print(f"Error: {input_path} is a database file, not a data file", file=sys.stderr)
19
+ sys.exit(1)
20
+
21
+ db = Database(args.db)
22
+ try:
23
+ if input_path.is_dir():
24
+ # Directory: look for README.md
25
+ readme_path = input_path / "README.md"
26
+ if readme_path.exists():
27
+ count = db.import_readme(readme_path)
28
+ print(f"Imported {count} records from README.md")
29
+ else:
30
+ print(f"Error: No README.md found in {input_path}", file=sys.stderr)
31
+ sys.exit(1)
32
+ elif input_path.suffix == ".md":
33
+ count = db.import_readme(input_path)
34
+ print(f"Imported {count} records from {input_path.name}")
35
+ else:
36
+ count = db.import_jsonl(input_path)
37
+ print(f"Imported {count} records from {input_path.name}")
38
+ finally:
39
+ db.close()
40
+
41
+
42
+ def _require_db(path, command):
43
+ """Check that path looks like a database, not JSONL."""
44
+ p = Path(path)
45
+ if p.suffix in (".jsonl", ".json"):
46
+ name = p.name
47
+ print(
48
+ f"Error: {name} is a JSONL file, not a SQLite database.\n"
49
+ f"Import it first:\n"
50
+ f" arkiv import {name} --db archive.db\n"
51
+ f" arkiv {command} archive.db ...",
52
+ file=sys.stderr,
53
+ )
54
+ sys.exit(1)
55
+
56
+
57
+ def cmd_export(args):
58
+ """Export SQLite database to JSONL files + README.md + schema.yaml."""
59
+ from .database import Database
60
+
61
+ _require_db(args.db, "export")
62
+ db = Database(args.db, read_only=True)
63
+ db.export(args.output, nested=args.nested, since=args.since, until=args.until)
64
+ db.close()
65
+ print(f"Exported to {args.output}")
66
+
67
+
68
+ def cmd_schema(args):
69
+ """Show schema from a JSONL file or SQLite database."""
70
+ input_path = Path(args.input)
71
+
72
+ if input_path.suffix == ".db":
73
+ from .database import Database
74
+
75
+ db = Database(input_path, read_only=True)
76
+ output = db.get_schema()
77
+ db.close()
78
+ else:
79
+ from .schema import discover_schema
80
+
81
+ schema = discover_schema(input_path)
82
+ output = {key: entry.to_dict() for key, entry in schema.items()}
83
+
84
+ print(json.dumps(output, indent=2))
85
+
86
+
87
+ def cmd_query(args):
88
+ """Run a SQL query against the database."""
89
+ from .database import Database
90
+
91
+ _require_db(args.db, "query")
92
+ db = Database(args.db, read_only=True)
93
+ results = db.query(args.sql)
94
+ db.close()
95
+ print(json.dumps(results, indent=2, default=str))
96
+
97
+
98
+ def cmd_info(args):
99
+ """Print info about a JSONL file or SQLite database."""
100
+ input_path = Path(args.input)
101
+
102
+ if input_path.suffix == ".db":
103
+ from .database import Database
104
+
105
+ db = Database(input_path, read_only=True)
106
+ info = db.get_info()
107
+ db.close()
108
+ else:
109
+ from .record import parse_jsonl
110
+ from .schema import discover_schema
111
+
112
+ collection = input_path.stem
113
+ records = list(parse_jsonl(input_path))
114
+ schema = discover_schema(input_path)
115
+ metadata_keys = {key: entry.to_dict() for key, entry in schema.items()}
116
+
117
+ coll_info = {"record_count": len(records)}
118
+ if metadata_keys:
119
+ coll_info["metadata_keys"] = metadata_keys
120
+
121
+ info = {
122
+ "total_records": len(records),
123
+ "collections": {collection: coll_info},
124
+ }
125
+
126
+ print(json.dumps(info, indent=2))
127
+
128
+
129
+ def _require_jsonl(path, suggestion):
130
+ """Check that path is a JSONL file, not a database."""
131
+ p = Path(path)
132
+ if p.suffix == ".db":
133
+ print(
134
+ f"Error: {p.name} is a SQLite database, not a JSONL file.\n"
135
+ f"Try: arkiv {suggestion} {p.name}",
136
+ file=sys.stderr,
137
+ )
138
+ sys.exit(1)
139
+
140
+
141
+ def cmd_detect(args):
142
+ """Check if a JSONL file is valid arkiv format."""
143
+ from .record import KNOWN_FIELDS
144
+
145
+ input_path = Path(args.input)
146
+ _require_jsonl(input_path, "info")
147
+ field_suggestions = {
148
+ "url": "uri", "link": "uri", "href": "uri",
149
+ "type": "mimetype", "mime": "mimetype",
150
+ }
151
+
152
+ total = 0
153
+ errors = 0
154
+ fields_used = set()
155
+ unknown_fields = set()
156
+ metadata_keys = set()
157
+ warnings = []
158
+
159
+ with open(input_path, encoding="utf-8") as f:
160
+ for lineno, line in enumerate(f, 1):
161
+ line = line.strip()
162
+ if not line:
163
+ continue
164
+ try:
165
+ obj = json.loads(line)
166
+ except json.JSONDecodeError:
167
+ warnings.append(f"Line {lineno}: invalid JSON")
168
+ errors += 1
169
+ continue
170
+ if not isinstance(obj, dict):
171
+ warnings.append(f"Line {lineno}: not a JSON object")
172
+ errors += 1
173
+ continue
174
+
175
+ total += 1
176
+ for key in obj:
177
+ if key in KNOWN_FIELDS:
178
+ fields_used.add(key)
179
+ else:
180
+ unknown_fields.add(key)
181
+ if isinstance(obj.get("metadata"), dict):
182
+ metadata_keys.update(obj["metadata"].keys())
183
+
184
+ for field in sorted(unknown_fields):
185
+ suggestion = field_suggestions.get(field)
186
+ if suggestion:
187
+ warnings.append(
188
+ f"Unknown field '{field}' — did you mean '{suggestion}'?"
189
+ )
190
+ else:
191
+ warnings.append(
192
+ f"Unknown field '{field}' (will be merged into metadata on import)"
193
+ )
194
+
195
+ # Schema validation: check sibling schema.yaml if present
196
+ schema_checks = []
197
+ schema_yaml_path = input_path.parent / "schema.yaml"
198
+ if schema_yaml_path.exists():
199
+ from .schema import discover_schema, load_schema_yaml
200
+
201
+ curated = load_schema_yaml(schema_yaml_path)
202
+ collection = input_path.stem
203
+ if collection in curated:
204
+ coll_schema = curated[collection]
205
+ curated_key_names = set(coll_schema.metadata_keys.keys())
206
+
207
+ # Keys in schema.yaml but not in data
208
+ for key in sorted(curated_key_names - metadata_keys):
209
+ warnings.append(
210
+ f"Schema key '{key}' not found in data"
211
+ )
212
+
213
+ # Keys in data but not in schema.yaml
214
+ for key in sorted(metadata_keys - curated_key_names):
215
+ schema_checks.append(
216
+ f"Undocumented key '{key}' (in data but not schema.yaml)"
217
+ )
218
+
219
+ # Type and value mismatches
220
+ auto_schema = discover_schema(input_path)
221
+ for key in sorted(curated_key_names & metadata_keys):
222
+ curated_entry = coll_schema.metadata_keys[key]
223
+ if key in auto_schema and auto_schema[key].type != curated_entry.type:
224
+ warnings.append(
225
+ f"Type mismatch for '{key}': schema says {curated_entry.type}, data has {auto_schema[key].type}"
226
+ )
227
+
228
+ if curated_entry.values and key in auto_schema and auto_schema[key].values:
229
+ missing_vals = set(str(v) for v in curated_entry.values) - set(
230
+ str(v) for v in auto_schema[key].values
231
+ )
232
+ if missing_vals:
233
+ schema_checks.append(
234
+ f"Curated values for '{key}' not in data: {sorted(missing_vals)}"
235
+ )
236
+
237
+ result = {
238
+ "valid_jsonl": errors == 0,
239
+ "total_records": total,
240
+ "collection": input_path.stem,
241
+ "fields_used": sorted(fields_used),
242
+ "unknown_fields": sorted(unknown_fields),
243
+ "metadata_keys": sorted(metadata_keys),
244
+ "warnings": warnings,
245
+ }
246
+ if schema_checks:
247
+ result["schema_info"] = schema_checks
248
+ print(json.dumps(result, indent=2))
249
+
250
+ if args.strict and warnings:
251
+ sys.exit(1)
252
+
253
+
254
+ def cmd_fix(args):
255
+ """Fix known field misspellings in a JSONL file."""
256
+ input_path = Path(args.input)
257
+ _require_jsonl(input_path, "info")
258
+ fix_map = {"url": "uri", "link": "uri", "href": "uri"}
259
+
260
+ lines = input_path.read_text(encoding="utf-8").splitlines(keepends=True)
261
+ fixed_count = 0
262
+ out_lines = []
263
+
264
+ for line in lines:
265
+ stripped = line.strip()
266
+ if not stripped:
267
+ out_lines.append(line)
268
+ continue
269
+ try:
270
+ obj = json.loads(stripped)
271
+ except json.JSONDecodeError:
272
+ out_lines.append(line)
273
+ continue
274
+ if not isinstance(obj, dict):
275
+ out_lines.append(line)
276
+ continue
277
+
278
+ changed = False
279
+ for unknown_field, target_field in fix_map.items():
280
+ if unknown_field in obj and target_field not in obj:
281
+ obj[target_field] = obj[unknown_field]
282
+ changed = True
283
+ fixed_count += 1
284
+
285
+ if changed:
286
+ out_lines.append(json.dumps(obj, ensure_ascii=False) + "\n")
287
+ else:
288
+ out_lines.append(line)
289
+
290
+ input_path.write_text("".join(out_lines), encoding="utf-8")
291
+ print(json.dumps({"fixed": fixed_count, "file": str(input_path)}, indent=2))
292
+
293
+
294
+ def cmd_mcp(args):
295
+ """Start the MCP server."""
296
+ from .server import run_mcp_server
297
+
298
+ run_mcp_server(db_path=args.db, writable=getattr(args, 'writable', False))
299
+
300
+
301
+ def main():
302
+ parser = argparse.ArgumentParser(
303
+ prog="arkiv",
304
+ description="Universal personal data format. JSONL in, SQL out, MCP to LLMs.",
305
+ )
306
+ parser.add_argument(
307
+ "--version", action="version", version=f"arkiv {__version__}"
308
+ )
309
+
310
+ subparsers = parser.add_subparsers(dest="command")
311
+
312
+ # import
313
+ p_import = subparsers.add_parser("import", help="Import JSONL, README.md, or directory")
314
+ p_import.add_argument("input", help="JSONL file, README.md, or directory")
315
+ p_import.add_argument(
316
+ "--db", default="archive.db", help="SQLite database path"
317
+ )
318
+ p_import.set_defaults(func=cmd_import)
319
+
320
+ # export
321
+ p_export = subparsers.add_parser(
322
+ "export", help="Export database to JSONL + README.md + schema.yaml"
323
+ )
324
+ p_export.add_argument("db", help="SQLite database path")
325
+ p_export.add_argument(
326
+ "--output", default="./exported", help="Output directory"
327
+ )
328
+ p_export.add_argument("--nested", action="store_true", help="Create subdirectory per collection")
329
+ p_export.add_argument("--since", help="Include records from this ISO 8601 date")
330
+ p_export.add_argument("--until", help="Include records through this ISO 8601 date")
331
+ p_export.set_defaults(func=cmd_export)
332
+
333
+ # schema
334
+ p_schema = subparsers.add_parser(
335
+ "schema", help="Show schema from JSONL file or database"
336
+ )
337
+ p_schema.add_argument("input", help="JSONL file or SQLite database")
338
+ p_schema.set_defaults(func=cmd_schema)
339
+
340
+ # query
341
+ p_query = subparsers.add_parser("query", help="Run SQL query")
342
+ p_query.add_argument("db", help="SQLite database path")
343
+ p_query.add_argument("sql", help="SQL query")
344
+ p_query.set_defaults(func=cmd_query)
345
+
346
+ # info
347
+ p_info = subparsers.add_parser(
348
+ "info", help="Show info about JSONL file or database"
349
+ )
350
+ p_info.add_argument("input", help="JSONL file or SQLite database")
351
+ p_info.set_defaults(func=cmd_info)
352
+
353
+ # detect
354
+ p_detect = subparsers.add_parser(
355
+ "detect", help="Check if a JSONL file is valid arkiv format"
356
+ )
357
+ p_detect.add_argument("input", help="JSONL file to check")
358
+ p_detect.add_argument(
359
+ "--strict", action="store_true", help="Exit 1 if any warnings"
360
+ )
361
+ p_detect.set_defaults(func=cmd_detect)
362
+
363
+ # fix
364
+ p_fix = subparsers.add_parser(
365
+ "fix", help="Fix known field misspellings in JSONL (e.g. url -> uri)"
366
+ )
367
+ p_fix.add_argument("input", help="JSONL file to fix")
368
+ p_fix.set_defaults(func=cmd_fix)
369
+
370
+ # mcp
371
+ p_mcp = subparsers.add_parser("mcp", help="Start MCP server")
372
+ p_mcp.add_argument("db", help="SQLite database path")
373
+ p_mcp.add_argument("--writable", action="store_true",
374
+ help="Enable write_record tool (default: read-only)")
375
+ p_mcp.set_defaults(func=cmd_mcp)
376
+
377
+ args = parser.parse_args()
378
+ if not args.command:
379
+ parser.print_help()
380
+ sys.exit(1)
381
+
382
+ try:
383
+ args.func(args)
384
+ except (FileNotFoundError, ValueError) as e:
385
+ print(f"Error: {e}", file=sys.stderr)
386
+ sys.exit(1)
387
+ except UnicodeDecodeError:
388
+ print("Error: File is not valid UTF-8 text (is it a binary file?)", file=sys.stderr)
389
+ sys.exit(1)
390
+
391
+
392
+ if __name__ == "__main__":
393
+ main()