okf-parser 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.
- okf_parser/__init__.py +13 -0
- okf_parser/bundle.py +368 -0
- okf_parser/cli.py +142 -0
- okf_parser/discovery.py +34 -0
- okf_parser/duckdb.py +87 -0
- okf_parser/formatting.py +46 -0
- okf_parser/models.py +86 -0
- okf_parser/parser.py +123 -0
- okf_parser/service.py +92 -0
- okf_parser-0.1.0.dist-info/METADATA +165 -0
- okf_parser-0.1.0.dist-info/RECORD +13 -0
- okf_parser-0.1.0.dist-info/WHEEL +4 -0
- okf_parser-0.1.0.dist-info/entry_points.txt +3 -0
okf_parser/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Relational inspection and validation for Open Knowledge Format bundles."""
|
|
2
|
+
|
|
3
|
+
from okf_parser.bundle import Bundle, load_bundle, validate_path
|
|
4
|
+
from okf_parser.models import Severity, ValidationReport, Violation
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"Bundle",
|
|
8
|
+
"Severity",
|
|
9
|
+
"ValidationReport",
|
|
10
|
+
"Violation",
|
|
11
|
+
"load_bundle",
|
|
12
|
+
"validate_path",
|
|
13
|
+
]
|
okf_parser/bundle.py
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""Load an OKF bundle into Ibis relations and validate its structure."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import asdict, dataclass
|
|
8
|
+
from datetime import date
|
|
9
|
+
from typing import TYPE_CHECKING, cast
|
|
10
|
+
from urllib.parse import urlsplit
|
|
11
|
+
|
|
12
|
+
import ibis
|
|
13
|
+
import networkx as nx
|
|
14
|
+
|
|
15
|
+
from okf_parser.discovery import discover_markdown
|
|
16
|
+
from okf_parser.models import (
|
|
17
|
+
ConceptRecord,
|
|
18
|
+
LinkRecord,
|
|
19
|
+
ReservedRecord,
|
|
20
|
+
Severity,
|
|
21
|
+
ValidationReport,
|
|
22
|
+
Violation,
|
|
23
|
+
)
|
|
24
|
+
from okf_parser.parser import (
|
|
25
|
+
DocumentParseError,
|
|
26
|
+
concept_id,
|
|
27
|
+
is_reserved_document,
|
|
28
|
+
iter_markdown_links,
|
|
29
|
+
parse_document,
|
|
30
|
+
resolve_local_target,
|
|
31
|
+
split_optional_frontmatter,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from collections.abc import Sequence
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
from ibis.expr.types import Table
|
|
39
|
+
|
|
40
|
+
_CONCEPT_SCHEMA = ibis.schema(
|
|
41
|
+
{
|
|
42
|
+
"concept_id": "string",
|
|
43
|
+
"logical_key": "string",
|
|
44
|
+
"path": "string",
|
|
45
|
+
"concept_type": "string",
|
|
46
|
+
"title": "string",
|
|
47
|
+
"description": "string",
|
|
48
|
+
"frontmatter_json": "string",
|
|
49
|
+
"body": "string",
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
_RESERVED_SCHEMA = ibis.schema({"path": "string", "filename": "string", "body": "string"})
|
|
53
|
+
_LINK_SCHEMA = ibis.schema(
|
|
54
|
+
{
|
|
55
|
+
"source_id": "string",
|
|
56
|
+
"raw_target": "string",
|
|
57
|
+
"target_id": "string",
|
|
58
|
+
"exists": "boolean",
|
|
59
|
+
"origin": "string",
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
_H1_RE = re.compile(r"^# [^#\n].*$", re.MULTILINE)
|
|
63
|
+
_H2_RE = re.compile(r"^## ([^#\n].*)$", re.MULTILINE)
|
|
64
|
+
_ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
65
|
+
type TableRecord = ConceptRecord | ReservedRecord | LinkRecord
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _table(records: Sequence[TableRecord], schema: ibis.Schema) -> Table:
|
|
69
|
+
rows = [asdict(record) for record in records]
|
|
70
|
+
return ibis.memtable(rows, schema=schema)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True, slots=True)
|
|
74
|
+
class Bundle:
|
|
75
|
+
"""An immutable relational view of one OKF bundle."""
|
|
76
|
+
|
|
77
|
+
root: Path
|
|
78
|
+
concepts: Table
|
|
79
|
+
reserved: Table
|
|
80
|
+
links: Table
|
|
81
|
+
diagnostics: tuple[Violation, ...]
|
|
82
|
+
markdown_count: int
|
|
83
|
+
|
|
84
|
+
def validate(self) -> list[Violation]:
|
|
85
|
+
"""Return deterministic diagnostics ordered by path, severity, and code."""
|
|
86
|
+
return sorted(
|
|
87
|
+
self.diagnostics,
|
|
88
|
+
key=lambda item: (item.path, item.severity.value, item.code, item.message),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def is_conformant(self) -> bool:
|
|
93
|
+
"""Whether the bundle has no normative errors."""
|
|
94
|
+
return not any(item.severity is Severity.ERROR for item in self.diagnostics)
|
|
95
|
+
|
|
96
|
+
def to_networkx(self) -> nx.MultiDiGraph:
|
|
97
|
+
"""Project concepts and resolved Markdown links into a directed graph."""
|
|
98
|
+
graph = nx.MultiDiGraph(bundle_root=str(self.root))
|
|
99
|
+
for row in self.concepts.execute().to_dict(orient="records"):
|
|
100
|
+
graph.add_node(
|
|
101
|
+
row["concept_id"],
|
|
102
|
+
path=row["path"],
|
|
103
|
+
type=row["concept_type"],
|
|
104
|
+
title=row["title"],
|
|
105
|
+
)
|
|
106
|
+
for row in self.links.execute().to_dict(orient="records"):
|
|
107
|
+
if row["exists"] and isinstance(row["target_id"], str):
|
|
108
|
+
graph.add_edge(
|
|
109
|
+
row["source_id"],
|
|
110
|
+
row["target_id"],
|
|
111
|
+
raw_target=row["raw_target"],
|
|
112
|
+
origin=row["origin"],
|
|
113
|
+
)
|
|
114
|
+
return graph
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _load_concept(
|
|
118
|
+
root: Path,
|
|
119
|
+
path: Path,
|
|
120
|
+
known_paths: set[Path],
|
|
121
|
+
) -> tuple[ConceptRecord | None, list[LinkRecord], list[Violation]]:
|
|
122
|
+
relative = path.relative_to(root).as_posix()
|
|
123
|
+
try:
|
|
124
|
+
parsed = parse_document(path)
|
|
125
|
+
except (DocumentParseError, OSError) as exc:
|
|
126
|
+
return (
|
|
127
|
+
None,
|
|
128
|
+
[],
|
|
129
|
+
[Violation("OKF001", Severity.ERROR, relative, str(exc))],
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
raw_type = parsed.frontmatter.get("type")
|
|
133
|
+
concept_type = raw_type.strip() if isinstance(raw_type, str) else ""
|
|
134
|
+
diagnostics: list[Violation] = []
|
|
135
|
+
if not concept_type:
|
|
136
|
+
diagnostics.append(
|
|
137
|
+
Violation(
|
|
138
|
+
"OKF002",
|
|
139
|
+
Severity.ERROR,
|
|
140
|
+
relative,
|
|
141
|
+
"frontmatter must contain a non-empty string type",
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
doc_id = concept_id(root, path)
|
|
146
|
+
links: list[LinkRecord] = []
|
|
147
|
+
raw_links = [(target, "body") for target in iter_markdown_links(parsed.body)]
|
|
148
|
+
raw_links.extend(_iter_frontmatter_links(parsed.frontmatter))
|
|
149
|
+
for raw_target, origin in raw_links:
|
|
150
|
+
resolved = resolve_local_target(root, path, raw_target)
|
|
151
|
+
is_markdown = urlsplit_path(raw_target).lower().endswith(".md")
|
|
152
|
+
if resolved is None or not is_markdown:
|
|
153
|
+
continue
|
|
154
|
+
exists = resolved in known_paths
|
|
155
|
+
target_id = (
|
|
156
|
+
concept_id(root, resolved) if exists and not is_reserved_document(resolved) else None
|
|
157
|
+
)
|
|
158
|
+
links.append(LinkRecord(doc_id, raw_target, target_id, exists, origin))
|
|
159
|
+
if not exists:
|
|
160
|
+
diagnostics.append(
|
|
161
|
+
Violation(
|
|
162
|
+
"OKF101",
|
|
163
|
+
Severity.WARNING,
|
|
164
|
+
relative,
|
|
165
|
+
f"local Markdown link does not resolve: {raw_target}",
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
title = parsed.frontmatter.get("title")
|
|
170
|
+
description = parsed.frontmatter.get("description")
|
|
171
|
+
record = ConceptRecord(
|
|
172
|
+
concept_id=doc_id,
|
|
173
|
+
logical_key=doc_id,
|
|
174
|
+
path=relative,
|
|
175
|
+
concept_type=concept_type,
|
|
176
|
+
title=title if isinstance(title, str) else None,
|
|
177
|
+
description=description if isinstance(description, str) else None,
|
|
178
|
+
frontmatter_json=json.dumps(
|
|
179
|
+
parsed.frontmatter,
|
|
180
|
+
ensure_ascii=False,
|
|
181
|
+
sort_keys=True,
|
|
182
|
+
default=str,
|
|
183
|
+
),
|
|
184
|
+
body=parsed.body,
|
|
185
|
+
)
|
|
186
|
+
return record, links, diagnostics
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _iter_frontmatter_links(
|
|
190
|
+
value: object,
|
|
191
|
+
field_path: str = "frontmatter",
|
|
192
|
+
) -> list[tuple[str, str]]:
|
|
193
|
+
"""Find local Markdown references nested in producer-defined frontmatter."""
|
|
194
|
+
if isinstance(value, dict):
|
|
195
|
+
return [
|
|
196
|
+
item
|
|
197
|
+
for key, child in value.items()
|
|
198
|
+
for item in _iter_frontmatter_links(child, f"{field_path}.{key}")
|
|
199
|
+
]
|
|
200
|
+
if isinstance(value, list):
|
|
201
|
+
return [
|
|
202
|
+
item
|
|
203
|
+
for index, child in enumerate(value)
|
|
204
|
+
for item in _iter_frontmatter_links(child, f"{field_path}[{index}]")
|
|
205
|
+
]
|
|
206
|
+
if isinstance(value, str) and urlsplit_path(value).lower().endswith(".md"):
|
|
207
|
+
return [(value, field_path)]
|
|
208
|
+
return []
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def urlsplit_path(raw_target: str) -> str:
|
|
212
|
+
"""Return only a link target's URL path."""
|
|
213
|
+
return urlsplit(raw_target).path
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _validate_index(root: Path, path: Path, text: str) -> tuple[str, list[Violation]]:
|
|
217
|
+
relative = path.relative_to(root).as_posix()
|
|
218
|
+
diagnostics: list[Violation] = []
|
|
219
|
+
try:
|
|
220
|
+
frontmatter, body = split_optional_frontmatter(text)
|
|
221
|
+
except DocumentParseError as exc:
|
|
222
|
+
return text, [Violation("OKF004", Severity.ERROR, relative, str(exc))]
|
|
223
|
+
|
|
224
|
+
if frontmatter is not None:
|
|
225
|
+
if path.parent != root:
|
|
226
|
+
diagnostics.append(
|
|
227
|
+
Violation(
|
|
228
|
+
"OKF004",
|
|
229
|
+
Severity.ERROR,
|
|
230
|
+
relative,
|
|
231
|
+
"only the bundle-root index.md may contain frontmatter",
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
elif set(frontmatter) - {"okf_version"}:
|
|
235
|
+
diagnostics.append(
|
|
236
|
+
Violation(
|
|
237
|
+
"OKF004",
|
|
238
|
+
Severity.ERROR,
|
|
239
|
+
relative,
|
|
240
|
+
"root index.md frontmatter may contain only okf_version",
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
if _H1_RE.search(body) is None:
|
|
244
|
+
diagnostics.append(
|
|
245
|
+
Violation(
|
|
246
|
+
"OKF005",
|
|
247
|
+
Severity.ERROR,
|
|
248
|
+
relative,
|
|
249
|
+
"index.md must contain at least one level-one section",
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
return body, diagnostics
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _validate_log(root: Path, path: Path, text: str) -> tuple[str, list[Violation]]:
|
|
256
|
+
relative = path.relative_to(root).as_posix()
|
|
257
|
+
diagnostics: list[Violation] = []
|
|
258
|
+
try:
|
|
259
|
+
frontmatter, body = split_optional_frontmatter(text)
|
|
260
|
+
except DocumentParseError as exc:
|
|
261
|
+
return text, [Violation("OKF006", Severity.ERROR, relative, str(exc))]
|
|
262
|
+
if frontmatter is not None:
|
|
263
|
+
diagnostics.append(
|
|
264
|
+
Violation("OKF006", Severity.ERROR, relative, "log.md must not contain frontmatter")
|
|
265
|
+
)
|
|
266
|
+
if _H1_RE.search(body) is None:
|
|
267
|
+
diagnostics.append(
|
|
268
|
+
Violation(
|
|
269
|
+
"OKF007",
|
|
270
|
+
Severity.ERROR,
|
|
271
|
+
relative,
|
|
272
|
+
"log.md must contain a level-one title",
|
|
273
|
+
)
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
headings = _H2_RE.findall(body)
|
|
277
|
+
parsed_dates: list[date] = []
|
|
278
|
+
for heading in headings:
|
|
279
|
+
if _ISO_DATE_RE.fullmatch(heading) is None:
|
|
280
|
+
diagnostics.append(
|
|
281
|
+
Violation(
|
|
282
|
+
"OKF008",
|
|
283
|
+
Severity.ERROR,
|
|
284
|
+
relative,
|
|
285
|
+
f"log date heading must use YYYY-MM-DD: {heading}",
|
|
286
|
+
)
|
|
287
|
+
)
|
|
288
|
+
continue
|
|
289
|
+
try:
|
|
290
|
+
parsed_dates.append(date.fromisoformat(heading))
|
|
291
|
+
except ValueError:
|
|
292
|
+
diagnostics.append(
|
|
293
|
+
Violation(
|
|
294
|
+
"OKF008",
|
|
295
|
+
Severity.ERROR,
|
|
296
|
+
relative,
|
|
297
|
+
f"log date heading is not a real date: {heading}",
|
|
298
|
+
)
|
|
299
|
+
)
|
|
300
|
+
if parsed_dates != sorted(parsed_dates, reverse=True):
|
|
301
|
+
diagnostics.append(
|
|
302
|
+
Violation(
|
|
303
|
+
"OKF009",
|
|
304
|
+
Severity.ERROR,
|
|
305
|
+
relative,
|
|
306
|
+
"log date groups must be ordered newest first",
|
|
307
|
+
)
|
|
308
|
+
)
|
|
309
|
+
return body, diagnostics
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def load_bundle(root: Path) -> Bundle:
|
|
313
|
+
"""Scan a directory and compile its OKF documents into Ibis tables."""
|
|
314
|
+
root = root.resolve()
|
|
315
|
+
if not root.is_dir():
|
|
316
|
+
msg = f"bundle root is not a directory: {root}"
|
|
317
|
+
raise NotADirectoryError(msg)
|
|
318
|
+
|
|
319
|
+
paths = discover_markdown(root)
|
|
320
|
+
known_paths = {path.resolve() for path in paths}
|
|
321
|
+
concepts: list[ConceptRecord] = []
|
|
322
|
+
reserved: list[ReservedRecord] = []
|
|
323
|
+
links: list[LinkRecord] = []
|
|
324
|
+
diagnostics: list[Violation] = []
|
|
325
|
+
|
|
326
|
+
for path in paths:
|
|
327
|
+
relative = path.relative_to(root).as_posix()
|
|
328
|
+
if is_reserved_document(path):
|
|
329
|
+
try:
|
|
330
|
+
text = path.read_text(encoding="utf-8")
|
|
331
|
+
except (UnicodeDecodeError, OSError) as exc:
|
|
332
|
+
diagnostics.append(Violation("OKF003", Severity.ERROR, relative, str(exc)))
|
|
333
|
+
continue
|
|
334
|
+
if path.name == "index.md":
|
|
335
|
+
body, reserved_diagnostics = _validate_index(root, path, text)
|
|
336
|
+
else:
|
|
337
|
+
body, reserved_diagnostics = _validate_log(root, path, text)
|
|
338
|
+
diagnostics.extend(reserved_diagnostics)
|
|
339
|
+
reserved.append(ReservedRecord(relative, path.name, body))
|
|
340
|
+
continue
|
|
341
|
+
|
|
342
|
+
record, document_links, document_diagnostics = _load_concept(root, path, known_paths)
|
|
343
|
+
if record is not None:
|
|
344
|
+
concepts.append(record)
|
|
345
|
+
links.extend(document_links)
|
|
346
|
+
diagnostics.extend(document_diagnostics)
|
|
347
|
+
|
|
348
|
+
return Bundle(
|
|
349
|
+
root=root,
|
|
350
|
+
concepts=_table(concepts, _CONCEPT_SCHEMA),
|
|
351
|
+
reserved=_table(reserved, _RESERVED_SCHEMA),
|
|
352
|
+
links=_table(links, _LINK_SCHEMA),
|
|
353
|
+
diagnostics=tuple(diagnostics),
|
|
354
|
+
markdown_count=len(paths),
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def validate_path(path: Path) -> ValidationReport:
|
|
359
|
+
"""Validate every Markdown file recursively below a path as OKF v0.2."""
|
|
360
|
+
bundle = load_bundle(path)
|
|
361
|
+
violations = tuple(bundle.validate())
|
|
362
|
+
return ValidationReport(
|
|
363
|
+
root=bundle.root,
|
|
364
|
+
markdown_count=bundle.markdown_count,
|
|
365
|
+
concept_count=cast("int", bundle.concepts.count().execute()),
|
|
366
|
+
reserved_count=cast("int", bundle.reserved.count().execute()),
|
|
367
|
+
violations=violations,
|
|
368
|
+
)
|
okf_parser/cli.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Expose okf-parser through Cyclopts and FastMCP."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
from cyclopts import App
|
|
11
|
+
from fastmcp import FastMCP
|
|
12
|
+
|
|
13
|
+
from okf_parser.service import (
|
|
14
|
+
check_bundle,
|
|
15
|
+
check_format,
|
|
16
|
+
export_duckdb,
|
|
17
|
+
graph_bundle,
|
|
18
|
+
inventory_bundle,
|
|
19
|
+
write_format,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
type McpTransport = Literal["stdio", "http", "sse"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class CliResult:
|
|
27
|
+
"""A JSON payload and its intended process exit code."""
|
|
28
|
+
|
|
29
|
+
payload: dict[str, object]
|
|
30
|
+
exit_code: int = 0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _render_cli_result(result: object) -> None:
|
|
34
|
+
"""Render stable JSON and preserve command-specific exit status."""
|
|
35
|
+
if not isinstance(result, CliResult):
|
|
36
|
+
return
|
|
37
|
+
sys.stdout.write(
|
|
38
|
+
json.dumps(result.payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
|
|
39
|
+
)
|
|
40
|
+
if result.exit_code:
|
|
41
|
+
raise SystemExit(result.exit_code)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
app = App(
|
|
45
|
+
name="okf-parser",
|
|
46
|
+
help="Validate and inspect OKF bundles with Ibis and NetworkX.",
|
|
47
|
+
result_action=_render_cli_result,
|
|
48
|
+
)
|
|
49
|
+
mcp = FastMCP(
|
|
50
|
+
name="okf-parser",
|
|
51
|
+
instructions=(
|
|
52
|
+
"Deterministic tools for validating and inspecting Open Knowledge Format bundles. "
|
|
53
|
+
"Formatting checks are read-only; no tool exposed here rewrites files."
|
|
54
|
+
),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.command
|
|
59
|
+
def check(path: str) -> CliResult:
|
|
60
|
+
"""Validate every Markdown file recursively as OKF v0.2."""
|
|
61
|
+
payload = check_bundle(path)
|
|
62
|
+
return CliResult(payload, 0 if payload["conformant"] else 1)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@app.command
|
|
66
|
+
def inventory(path: str) -> CliResult:
|
|
67
|
+
"""Count concepts by type using an Ibis relation."""
|
|
68
|
+
return CliResult(inventory_bundle(path))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@app.command
|
|
72
|
+
def graph(path: str) -> CliResult:
|
|
73
|
+
"""Summarize the resolved concept graph with NetworkX."""
|
|
74
|
+
return CliResult(graph_bundle(path))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@app.command(name="format")
|
|
78
|
+
def format_command(path: str, *, write: bool = False) -> CliResult:
|
|
79
|
+
"""Check mdformat style, writing only when --write is explicit."""
|
|
80
|
+
payload = write_format(path) if write else check_format(path)
|
|
81
|
+
return CliResult(payload, 0 if payload["clean"] or write else 1)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@app.command(name="duckdb")
|
|
85
|
+
def duckdb_command(
|
|
86
|
+
path: str,
|
|
87
|
+
database: str = "okf.duckdb",
|
|
88
|
+
schema: str = "okf",
|
|
89
|
+
) -> CliResult:
|
|
90
|
+
"""Materialize bundle relations into a DuckDB database."""
|
|
91
|
+
return CliResult(export_duckdb(path, database, schema))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@app.command
|
|
95
|
+
def serve(
|
|
96
|
+
transport: McpTransport = "stdio",
|
|
97
|
+
host: str = "127.0.0.1",
|
|
98
|
+
port: int = 8000,
|
|
99
|
+
) -> None:
|
|
100
|
+
"""Serve read-only inspection tools through MCP."""
|
|
101
|
+
if transport == "stdio":
|
|
102
|
+
mcp.run()
|
|
103
|
+
return
|
|
104
|
+
mcp.run(transport=transport, host=host, port=port)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@mcp.tool(name="check")
|
|
108
|
+
def mcp_check(path: str) -> dict[str, object]:
|
|
109
|
+
"""Validate every Markdown file recursively as OKF v0.2."""
|
|
110
|
+
return check_bundle(path)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@mcp.tool(name="inventory")
|
|
114
|
+
def mcp_inventory(path: str) -> dict[str, object]:
|
|
115
|
+
"""Count concepts by type."""
|
|
116
|
+
return inventory_bundle(path)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@mcp.tool(name="graph")
|
|
120
|
+
def mcp_graph(path: str) -> dict[str, object]:
|
|
121
|
+
"""Summarize resolved concept relationships."""
|
|
122
|
+
return graph_bundle(path)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@mcp.tool(name="format_check")
|
|
126
|
+
def mcp_format_check(path: str) -> dict[str, object]:
|
|
127
|
+
"""Check mdformat style without modifying files."""
|
|
128
|
+
return check_format(path)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def run_mcp_stdio() -> None:
|
|
132
|
+
"""Run the MCP server over stdio."""
|
|
133
|
+
mcp.run()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def main() -> None:
|
|
137
|
+
"""Run the Cyclopts command-line application."""
|
|
138
|
+
app()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
if __name__ == "__main__":
|
|
142
|
+
main()
|
okf_parser/discovery.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Shared, symlink-safe Markdown discovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
IGNORED_DIRECTORIES = frozenset(
|
|
11
|
+
{".git", ".mypy_cache", ".pytest_cache", ".ruff_cache", ".ty_cache", ".venv"}
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def discover_markdown(root: Path) -> tuple[Path, ...]:
|
|
16
|
+
"""Return authored Markdown files without traversing environments or symlinks."""
|
|
17
|
+
resolved_root = root.resolve()
|
|
18
|
+
if not resolved_root.is_dir():
|
|
19
|
+
msg = f"Markdown root is not a directory: {resolved_root}"
|
|
20
|
+
raise NotADirectoryError(msg)
|
|
21
|
+
|
|
22
|
+
paths: list[Path] = []
|
|
23
|
+
for directory, directory_names, filenames in resolved_root.walk(follow_symlinks=False):
|
|
24
|
+
directory_names[:] = [
|
|
25
|
+
name
|
|
26
|
+
for name in directory_names
|
|
27
|
+
if name not in IGNORED_DIRECTORIES and not (directory / name).is_symlink()
|
|
28
|
+
]
|
|
29
|
+
paths.extend(
|
|
30
|
+
candidate
|
|
31
|
+
for name in filenames
|
|
32
|
+
if name.endswith(".md") and not (candidate := directory / name).is_symlink()
|
|
33
|
+
)
|
|
34
|
+
return tuple(sorted(paths))
|
okf_parser/duckdb.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Materialize an OKF bundle as ordinary DuckDB tables."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING, cast
|
|
8
|
+
|
|
9
|
+
import ibis
|
|
10
|
+
|
|
11
|
+
from okf_parser.bundle import Bundle, load_bundle
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
import duckdb
|
|
15
|
+
|
|
16
|
+
_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
17
|
+
_DIAGNOSTIC_SCHEMA = ibis.schema(
|
|
18
|
+
{
|
|
19
|
+
"code": "string",
|
|
20
|
+
"severity": "string",
|
|
21
|
+
"path": "string",
|
|
22
|
+
"message": "string",
|
|
23
|
+
}
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _validate_schema_name(schema: str) -> None:
|
|
28
|
+
if _IDENTIFIER_RE.fullmatch(schema) is None:
|
|
29
|
+
msg = f"invalid DuckDB schema name: {schema!r}"
|
|
30
|
+
raise ValueError(msg)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _diagnostics_table(bundle: Bundle) -> ibis.Table:
|
|
34
|
+
rows = [
|
|
35
|
+
{
|
|
36
|
+
"code": item.code,
|
|
37
|
+
"severity": item.severity.value,
|
|
38
|
+
"path": item.path,
|
|
39
|
+
"message": item.message,
|
|
40
|
+
}
|
|
41
|
+
for item in bundle.validate()
|
|
42
|
+
]
|
|
43
|
+
return ibis.memtable(rows, schema=_DIAGNOSTIC_SCHEMA)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def attach_okf(
|
|
47
|
+
connection: duckdb.DuckDBPyConnection,
|
|
48
|
+
path: str | Path,
|
|
49
|
+
*,
|
|
50
|
+
schema: str = "okf",
|
|
51
|
+
) -> dict[str, object]:
|
|
52
|
+
"""Materialize one OKF bundle into a DuckDB schema.
|
|
53
|
+
|
|
54
|
+
The function creates four ordinary tables inside ``schema``:
|
|
55
|
+
``concepts``, ``links``, ``reserved``, and ``diagnostics``. Once copied,
|
|
56
|
+
the tables are independent of Python and remain queryable from any
|
|
57
|
+
DuckDB client that opens the database.
|
|
58
|
+
"""
|
|
59
|
+
_validate_schema_name(schema)
|
|
60
|
+
bundle = load_bundle(Path(path))
|
|
61
|
+
relations = {
|
|
62
|
+
"concepts": bundle.concepts,
|
|
63
|
+
"links": bundle.links,
|
|
64
|
+
"reserved": bundle.reserved,
|
|
65
|
+
"diagnostics": _diagnostics_table(bundle),
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
connection.execute("BEGIN TRANSACTION")
|
|
69
|
+
try:
|
|
70
|
+
connection.execute(f'CREATE SCHEMA IF NOT EXISTS "{schema}"')
|
|
71
|
+
for table_name, relation in relations.items():
|
|
72
|
+
qualified_name = f"{schema}.{table_name}"
|
|
73
|
+
connection.from_arrow(relation.to_pyarrow()).create(qualified_name)
|
|
74
|
+
connection.execute("COMMIT")
|
|
75
|
+
except Exception:
|
|
76
|
+
connection.execute("ROLLBACK")
|
|
77
|
+
raise
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
"schema": schema,
|
|
81
|
+
"root": str(bundle.root),
|
|
82
|
+
"conformant": bundle.is_conformant,
|
|
83
|
+
"markdown_count": bundle.markdown_count,
|
|
84
|
+
"concept_count": cast("int", bundle.concepts.count().execute()),
|
|
85
|
+
"link_count": cast("int", bundle.links.count().execute()),
|
|
86
|
+
"diagnostic_count": len(bundle.diagnostics),
|
|
87
|
+
}
|
okf_parser/formatting.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Optional Markdown formatting checks, separate from OKF conformance."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import mdformat
|
|
9
|
+
|
|
10
|
+
from okf_parser.discovery import discover_markdown
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class FormatReport:
|
|
18
|
+
"""Result of checking or formatting a Markdown tree."""
|
|
19
|
+
|
|
20
|
+
markdown_count: int
|
|
21
|
+
changed_paths: tuple[str, ...]
|
|
22
|
+
written: bool
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def clean(self) -> bool:
|
|
26
|
+
"""Whether every file was already in canonical mdformat form."""
|
|
27
|
+
return not self.changed_paths
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def format_path(path: Path, *, write: bool = False) -> FormatReport:
|
|
31
|
+
"""Check or explicitly rewrite every Markdown file below a path."""
|
|
32
|
+
root = path.resolve()
|
|
33
|
+
paths = discover_markdown(root)
|
|
34
|
+
changed: list[str] = []
|
|
35
|
+
for markdown_path in paths:
|
|
36
|
+
original = markdown_path.read_text(encoding="utf-8")
|
|
37
|
+
formatted = mdformat.text(
|
|
38
|
+
original,
|
|
39
|
+
extensions={"frontmatter", "gfm"},
|
|
40
|
+
)
|
|
41
|
+
if formatted == original:
|
|
42
|
+
continue
|
|
43
|
+
changed.append(markdown_path.relative_to(root).as_posix())
|
|
44
|
+
if write:
|
|
45
|
+
markdown_path.write_text(formatted, encoding="utf-8")
|
|
46
|
+
return FormatReport(len(paths), tuple(changed), write)
|
okf_parser/models.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Small immutable domain records used by the parser and validator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Severity(StrEnum):
|
|
14
|
+
"""A diagnostic's effect on conformance."""
|
|
15
|
+
|
|
16
|
+
ERROR = "error"
|
|
17
|
+
WARNING = "warning"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class Violation:
|
|
22
|
+
"""One deterministic bundle diagnostic."""
|
|
23
|
+
|
|
24
|
+
code: str
|
|
25
|
+
severity: Severity
|
|
26
|
+
path: str
|
|
27
|
+
message: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class ConceptRecord:
|
|
32
|
+
"""One parsed OKF concept document."""
|
|
33
|
+
|
|
34
|
+
concept_id: str
|
|
35
|
+
logical_key: str
|
|
36
|
+
path: str
|
|
37
|
+
concept_type: str
|
|
38
|
+
title: str | None
|
|
39
|
+
description: str | None
|
|
40
|
+
frontmatter_json: str
|
|
41
|
+
body: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class ReservedRecord:
|
|
46
|
+
"""One reserved index.md or log.md document."""
|
|
47
|
+
|
|
48
|
+
path: str
|
|
49
|
+
filename: str
|
|
50
|
+
body: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class LinkRecord:
|
|
55
|
+
"""One local Markdown relationship originating in a concept."""
|
|
56
|
+
|
|
57
|
+
source_id: str
|
|
58
|
+
raw_target: str
|
|
59
|
+
target_id: str | None
|
|
60
|
+
exists: bool
|
|
61
|
+
origin: str
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class ParsedDocument:
|
|
66
|
+
"""Result of parsing one concept without applying semantic rules."""
|
|
67
|
+
|
|
68
|
+
path: Path
|
|
69
|
+
frontmatter: dict[str, object]
|
|
70
|
+
body: str
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True, slots=True)
|
|
74
|
+
class ValidationReport:
|
|
75
|
+
"""Aggregate result for every Markdown file below one path."""
|
|
76
|
+
|
|
77
|
+
root: Path
|
|
78
|
+
markdown_count: int
|
|
79
|
+
concept_count: int
|
|
80
|
+
reserved_count: int
|
|
81
|
+
violations: tuple[Violation, ...]
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def is_conformant(self) -> bool:
|
|
85
|
+
"""Whether every Markdown file satisfies its OKF v0.2 rules."""
|
|
86
|
+
return not any(item.severity is Severity.ERROR for item in self.violations)
|
okf_parser/parser.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Filesystem-safe parsing primitives for OKF Markdown documents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
from urllib.parse import unquote, urlsplit
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
from markdown_it import MarkdownIt
|
|
11
|
+
|
|
12
|
+
from okf_parser.models import ParsedDocument
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
_FRONTMATTER_RE = re.compile(
|
|
18
|
+
r"\A---[ \t]*\r?\n(.*?)\r?\n---[ \t]*(?:\r?\n(.*))?\Z",
|
|
19
|
+
re.DOTALL,
|
|
20
|
+
)
|
|
21
|
+
RESERVED_FILENAMES = frozenset({"index.md", "log.md"})
|
|
22
|
+
_MARKDOWN = MarkdownIt("commonmark")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def is_reserved_document(path: Path) -> bool:
|
|
26
|
+
"""Return whether a Markdown file is metadata rather than an OKF concept."""
|
|
27
|
+
return path.name in RESERVED_FILENAMES
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DocumentParseError(ValueError):
|
|
31
|
+
"""Raised when one concept document cannot be structurally parsed."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def parse_document(path: Path) -> ParsedDocument:
|
|
35
|
+
"""Parse YAML frontmatter and preserve the Markdown body."""
|
|
36
|
+
try:
|
|
37
|
+
text = path.read_text(encoding="utf-8")
|
|
38
|
+
except UnicodeDecodeError as exc:
|
|
39
|
+
msg = "document must be valid UTF-8"
|
|
40
|
+
raise DocumentParseError(msg) from exc
|
|
41
|
+
|
|
42
|
+
match = _FRONTMATTER_RE.match(text.removeprefix("\ufeff"))
|
|
43
|
+
if match is None:
|
|
44
|
+
msg = "concept must start with YAML frontmatter delimited by ---"
|
|
45
|
+
raise DocumentParseError(msg)
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
value = yaml.safe_load(match.group(1))
|
|
49
|
+
except yaml.YAMLError as exc:
|
|
50
|
+
msg = f"invalid YAML frontmatter: {exc}"
|
|
51
|
+
raise DocumentParseError(msg) from exc
|
|
52
|
+
|
|
53
|
+
if value is None:
|
|
54
|
+
frontmatter: dict[str, object] = {}
|
|
55
|
+
elif isinstance(value, dict):
|
|
56
|
+
frontmatter = value
|
|
57
|
+
else:
|
|
58
|
+
msg = "frontmatter must be a YAML mapping"
|
|
59
|
+
raise DocumentParseError(msg)
|
|
60
|
+
|
|
61
|
+
return ParsedDocument(path=path, frontmatter=frontmatter, body=match.group(2) or "")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def split_optional_frontmatter(text: str) -> tuple[dict[str, object] | None, str]:
|
|
65
|
+
"""Split optional frontmatter for reserved documents."""
|
|
66
|
+
normalized = text.removeprefix("\ufeff")
|
|
67
|
+
if not normalized.startswith("---"):
|
|
68
|
+
return None, normalized
|
|
69
|
+
match = _FRONTMATTER_RE.match(normalized)
|
|
70
|
+
if match is None:
|
|
71
|
+
msg = "invalid YAML frontmatter delimiters"
|
|
72
|
+
raise DocumentParseError(msg)
|
|
73
|
+
try:
|
|
74
|
+
value = yaml.safe_load(match.group(1))
|
|
75
|
+
except yaml.YAMLError as exc:
|
|
76
|
+
msg = f"invalid YAML frontmatter: {exc}"
|
|
77
|
+
raise DocumentParseError(msg) from exc
|
|
78
|
+
if value is None:
|
|
79
|
+
return {}, match.group(2) or ""
|
|
80
|
+
if not isinstance(value, dict):
|
|
81
|
+
msg = "frontmatter must be a YAML mapping"
|
|
82
|
+
raise DocumentParseError(msg)
|
|
83
|
+
return value, match.group(2) or ""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def concept_id(bundle_root: Path, path: Path) -> str:
|
|
87
|
+
"""Derive the normative concept ID from a bundle-relative path."""
|
|
88
|
+
return path.relative_to(bundle_root).with_suffix("").as_posix()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def iter_markdown_links(body: str) -> list[str]:
|
|
92
|
+
"""Return non-image Markdown link targets in source order."""
|
|
93
|
+
links: list[str] = []
|
|
94
|
+
pending = list(reversed(_MARKDOWN.parse(body)))
|
|
95
|
+
while pending:
|
|
96
|
+
token = pending.pop()
|
|
97
|
+
if token.children:
|
|
98
|
+
pending.extend(reversed(token.children))
|
|
99
|
+
if token.type != "link_open":
|
|
100
|
+
continue
|
|
101
|
+
destination = token.attrGet("href")
|
|
102
|
+
if isinstance(destination, str):
|
|
103
|
+
links.append(destination)
|
|
104
|
+
return links
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def resolve_local_target(bundle_root: Path, source_path: Path, raw_target: str) -> Path | None:
|
|
108
|
+
"""Resolve one local Markdown target while preventing bundle escape."""
|
|
109
|
+
split = urlsplit(raw_target)
|
|
110
|
+
if split.scheme or split.netloc or not split.path:
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
decoded = unquote(split.path)
|
|
114
|
+
candidate = (
|
|
115
|
+
bundle_root / decoded.lstrip("/")
|
|
116
|
+
if decoded.startswith("/")
|
|
117
|
+
else source_path.parent / decoded
|
|
118
|
+
)
|
|
119
|
+
resolved = candidate.resolve()
|
|
120
|
+
root = bundle_root.resolve()
|
|
121
|
+
if not resolved.is_relative_to(root):
|
|
122
|
+
return None
|
|
123
|
+
return resolved
|
okf_parser/service.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""JSON-ready application services shared by CLI and MCP."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import duckdb
|
|
8
|
+
import networkx as nx
|
|
9
|
+
|
|
10
|
+
from okf_parser.bundle import load_bundle, validate_path
|
|
11
|
+
from okf_parser.duckdb import attach_okf
|
|
12
|
+
from okf_parser.formatting import format_path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def check_bundle(path: str) -> dict[str, object]:
|
|
16
|
+
"""Validate every Markdown file below a path."""
|
|
17
|
+
report = validate_path(Path(path))
|
|
18
|
+
return {
|
|
19
|
+
"root": str(report.root),
|
|
20
|
+
"conformant": report.is_conformant,
|
|
21
|
+
"markdown_count": report.markdown_count,
|
|
22
|
+
"concept_count": report.concept_count,
|
|
23
|
+
"reserved_count": report.reserved_count,
|
|
24
|
+
"diagnostics": [
|
|
25
|
+
{
|
|
26
|
+
"code": item.code,
|
|
27
|
+
"severity": item.severity.value,
|
|
28
|
+
"path": item.path,
|
|
29
|
+
"message": item.message,
|
|
30
|
+
}
|
|
31
|
+
for item in report.violations
|
|
32
|
+
],
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def inventory_bundle(path: str) -> dict[str, object]:
|
|
37
|
+
"""Count concepts by their producer-defined type."""
|
|
38
|
+
bundle = load_bundle(Path(path))
|
|
39
|
+
rows = (
|
|
40
|
+
bundle.concepts.group_by("concept_type")
|
|
41
|
+
.aggregate(concept_count=lambda table: table.count())
|
|
42
|
+
.order_by("concept_type")
|
|
43
|
+
.execute()
|
|
44
|
+
.to_dict(orient="records")
|
|
45
|
+
)
|
|
46
|
+
return {"root": str(bundle.root), "types": rows}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def graph_bundle(path: str) -> dict[str, object]:
|
|
50
|
+
"""Summarize the resolved concept graph."""
|
|
51
|
+
bundle = load_bundle(Path(path))
|
|
52
|
+
graph = bundle.to_networkx()
|
|
53
|
+
return {
|
|
54
|
+
"root": str(bundle.root),
|
|
55
|
+
"nodes": graph.number_of_nodes(),
|
|
56
|
+
"edges": graph.number_of_edges(),
|
|
57
|
+
"weakly_connected_components": nx.number_weakly_connected_components(graph),
|
|
58
|
+
"strongly_connected_components": nx.number_strongly_connected_components(graph),
|
|
59
|
+
"directed_acyclic": nx.is_directed_acyclic_graph(graph),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def check_format(path: str) -> dict[str, object]:
|
|
64
|
+
"""Check mdformat canonical form without writing files."""
|
|
65
|
+
report = format_path(Path(path))
|
|
66
|
+
return {
|
|
67
|
+
"markdown_count": report.markdown_count,
|
|
68
|
+
"clean": report.clean,
|
|
69
|
+
"changed_paths": list(report.changed_paths),
|
|
70
|
+
"written": report.written,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def write_format(path: str) -> dict[str, object]:
|
|
75
|
+
"""Explicitly rewrite Markdown files into mdformat canonical form."""
|
|
76
|
+
report = format_path(Path(path), write=True)
|
|
77
|
+
return {
|
|
78
|
+
"markdown_count": report.markdown_count,
|
|
79
|
+
"clean": report.clean,
|
|
80
|
+
"changed_paths": list(report.changed_paths),
|
|
81
|
+
"written": report.written,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def export_duckdb(path: str, database: str, schema: str = "okf") -> dict[str, object]:
|
|
86
|
+
"""Materialize an OKF bundle into a DuckDB database file."""
|
|
87
|
+
connection = duckdb.connect(database)
|
|
88
|
+
try:
|
|
89
|
+
result = attach_okf(connection, path, schema=schema)
|
|
90
|
+
finally:
|
|
91
|
+
connection.close()
|
|
92
|
+
return {**result, "database": str(Path(database).resolve())}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: okf-parser
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Relational inspection and validation for Open Knowledge Format bundles
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: cyclopts<5,>=4.22
|
|
7
|
+
Requires-Dist: duckdb<2,>=1.4
|
|
8
|
+
Requires-Dist: fastmcp<4,>=3.4
|
|
9
|
+
Requires-Dist: ibis-framework[duckdb]<13,>=12
|
|
10
|
+
Requires-Dist: markdown-it-py<5,>=4
|
|
11
|
+
Requires-Dist: mdformat-frontmatter<3,>=2
|
|
12
|
+
Requires-Dist: mdformat-gfm<2,>=1
|
|
13
|
+
Requires-Dist: mdformat<2,>=1
|
|
14
|
+
Requires-Dist: networkx<4,>=3.4
|
|
15
|
+
Requires-Dist: pyyaml<7,>=6
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
type: Project
|
|
20
|
+
title: okf-parser
|
|
21
|
+
description: Relational inspection and validation for Open Knowledge Format bundles
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
# okf-parser
|
|
25
|
+
|
|
26
|
+
Relational inspection and validation for
|
|
27
|
+
[Open Knowledge Format (OKF) v0.2](https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md)
|
|
28
|
+
bundles.
|
|
29
|
+
|
|
30
|
+
`okf-parser` reads an OKF bundle without imposing a domain taxonomy, preserves
|
|
31
|
+
unknown frontmatter fields, and exposes concepts and links as
|
|
32
|
+
[Ibis](https://ibis-project.org/) tables. This makes bundle-wide rules—identity,
|
|
33
|
+
lineage, cardinality, provenance, and profile-specific constraints—expressible
|
|
34
|
+
as deterministic relational checks.
|
|
35
|
+
|
|
36
|
+
## Why another OKF tool?
|
|
37
|
+
|
|
38
|
+
The ecosystem already has good static linters and generators, including
|
|
39
|
+
`okflint`, `okf-cli`, and `google-okf`. This project focuses on a different
|
|
40
|
+
layer:
|
|
41
|
+
|
|
42
|
+
- compile a bundle into queryable relational tables;
|
|
43
|
+
- project those same relations into a NetworkX graph;
|
|
44
|
+
- validate OKF v0.2 conformance without rejecting extensions;
|
|
45
|
+
- distinguish normative errors from advisory diagnostics;
|
|
46
|
+
- let projects add cross-concept rules as Ibis expressions;
|
|
47
|
+
- produce stable human-readable and JSON reports for CI and agents.
|
|
48
|
+
|
|
49
|
+
The parser and validation model are inspired by
|
|
50
|
+
[`franklinbaldo/sisprev`](https://github.com/franklinbaldo/sisprev): parse
|
|
51
|
+
documents independently from semantic validation, aggregate violations instead
|
|
52
|
+
of failing at the first bad concept, preserve authored bodies, and test
|
|
53
|
+
filesystem identity explicitly. No Sisprev-specific legal types are copied into
|
|
54
|
+
the core.
|
|
55
|
+
|
|
56
|
+
[`mrorigo/rust-okf`](https://github.com/mrorigo/rust-okf) inspired the stable
|
|
57
|
+
logical key, conservative metadata preservation, BOM/CRLF handling, and clean
|
|
58
|
+
separation between bundle parsing and downstream query surfaces. Its BM25,
|
|
59
|
+
vector index, storage format, and HTTP server are intentionally outside this
|
|
60
|
+
project's scope.
|
|
61
|
+
|
|
62
|
+
## Quick start
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
uv sync
|
|
66
|
+
uv run okf-parser check path/to/bundle
|
|
67
|
+
uv run okf-parser inventory path/to/bundle
|
|
68
|
+
uv run okf-parser graph path/to/bundle
|
|
69
|
+
uv run okf-parser format path/to/bundle
|
|
70
|
+
uv run okf-parser format path/to/bundle --write
|
|
71
|
+
uv run okf-parser duckdb path/to/bundle knowledge.duckdb
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The command exits with status `1` only when normative errors exist. Broken
|
|
75
|
+
cross-links are warnings because OKF v0.2 explicitly says they do not make a
|
|
76
|
+
bundle non-conformant.
|
|
77
|
+
|
|
78
|
+
## GitHub Actions
|
|
79
|
+
|
|
80
|
+
Add the repository as a CI check:
|
|
81
|
+
|
|
82
|
+
```yaml
|
|
83
|
+
steps:
|
|
84
|
+
- uses: actions/checkout@v4
|
|
85
|
+
- uses: franklinbaldo/okf-parser@v1
|
|
86
|
+
with:
|
|
87
|
+
path: knowledge
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The composite action installs a pinned uv version and executes the same
|
|
91
|
+
`validate_path()` function used by the Python API, CLI, and MCP server.
|
|
92
|
+
|
|
93
|
+
Releases are published to PyPI from GitHub Releases through OIDC Trusted
|
|
94
|
+
Publishing. No long-lived PyPI token is stored in the repository.
|
|
95
|
+
|
|
96
|
+
Every pull request must increase the SemVer version in `pyproject.toml` and add
|
|
97
|
+
exactly one matching `changelog/<version>.md` entry. CI compares both against
|
|
98
|
+
the target branch before allowing merge.
|
|
99
|
+
|
|
100
|
+
## MCP
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
uv run okf-parser serve
|
|
104
|
+
uv run okf-parser-mcp
|
|
105
|
+
uv run fastmcp run
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Read-only tools: `check`, `inventory`, `graph`, and `format_check`.
|
|
109
|
+
|
|
110
|
+
## DuckDB
|
|
111
|
+
|
|
112
|
+
`okf-parser` is a regular uv-managed Python app; the DuckDB integration is part
|
|
113
|
+
of the same package, not a native C++ subproject:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
import duckdb
|
|
117
|
+
|
|
118
|
+
from okf_parser.duckdb import attach_okf
|
|
119
|
+
|
|
120
|
+
connection = duckdb.connect("knowledge.duckdb")
|
|
121
|
+
attach_okf(connection, "knowledge/")
|
|
122
|
+
|
|
123
|
+
connection.sql("""
|
|
124
|
+
SELECT concept_type, count(*)
|
|
125
|
+
FROM okf.concepts
|
|
126
|
+
GROUP BY concept_type
|
|
127
|
+
""").show()
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
The call creates `okf.concepts`, `okf.links`, `okf.reserved`, and
|
|
131
|
+
`okf.diagnostics` as ordinary DuckDB tables. Use a new database or schema for
|
|
132
|
+
each materialization.
|
|
133
|
+
|
|
134
|
+
## Python API
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
from pathlib import Path
|
|
138
|
+
|
|
139
|
+
from okf_parser import load_bundle, validate_path
|
|
140
|
+
|
|
141
|
+
bundle = load_bundle(Path("knowledge"))
|
|
142
|
+
print(bundle.concepts.execute())
|
|
143
|
+
print(bundle.links.execute())
|
|
144
|
+
print(bundle.to_networkx())
|
|
145
|
+
print(bundle.validate())
|
|
146
|
+
|
|
147
|
+
report = validate_path(Path("knowledge"))
|
|
148
|
+
assert report.markdown_count == report.concept_count + report.reserved_count
|
|
149
|
+
assert report.is_conformant
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Current scope
|
|
153
|
+
|
|
154
|
+
- UTF-8 Markdown discovery;
|
|
155
|
+
- reserved `index.md` and `log.md` handling;
|
|
156
|
+
- strict YAML-frontmatter parsing for concept documents;
|
|
157
|
+
- required non-empty `type`;
|
|
158
|
+
- stable concept IDs derived from paths;
|
|
159
|
+
- Markdown-link extraction and resolution;
|
|
160
|
+
- Ibis tables for concepts, reserved documents, and links;
|
|
161
|
+
- NetworkX graph projection for traversal, cycles, components, and impact;
|
|
162
|
+
- aggregated validation reports.
|
|
163
|
+
|
|
164
|
+
Profiles, lifecycle/provenance family validation, external resources, and
|
|
165
|
+
pluggable Ibis rules are the next milestones.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
okf_parser/__init__.py,sha256=VC0sd-vtVbE5WLJG047xr8CpWkGZy7zVbMCBPLoIl_w,338
|
|
2
|
+
okf_parser/bundle.py,sha256=bnTWluxKEAT4Qq677TS6FbGqaTx8U65yjmkOmoOBfAk,12057
|
|
3
|
+
okf_parser/cli.py,sha256=7uXdsBLVral2OZQo4FqpI4lth_oTFFXYOnKkZb5bA6Y,3591
|
|
4
|
+
okf_parser/discovery.py,sha256=TB7dlc22YJCz-yklzpAZJfVVP85zdTcx90j4P35QHm8,1115
|
|
5
|
+
okf_parser/duckdb.py,sha256=hi_fm0DG85PK_lw2Y-kmDXpJKWAkoIh6rpl3YbrY40k,2510
|
|
6
|
+
okf_parser/formatting.py,sha256=M0elRV-mYA7ABnScqril56sr-L25NGYzYmwSQ9jkSLI,1351
|
|
7
|
+
okf_parser/models.py,sha256=YmthROgmtzKskzolbbJW0XeXS5RBi64t92zoeQVKU1c,1851
|
|
8
|
+
okf_parser/parser.py,sha256=SIN5jwljBv5BAnDMiFxBJsJkX_J2jtWXCwQVo085VSk,3989
|
|
9
|
+
okf_parser/service.py,sha256=xhirXIw7Ysg5QJw0VwcIyyRJ7qCieRnAvjTWcGLSwMw,2996
|
|
10
|
+
okf_parser-0.1.0.dist-info/METADATA,sha256=AFhbkJjTLq3iMWCDqMpWbkUP0ku5M2aED0EPKpxMwDU,5250
|
|
11
|
+
okf_parser-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
12
|
+
okf_parser-0.1.0.dist-info/entry_points.txt,sha256=4RSMWLWiBAVQ2Bf98e6unD_mL7sP0Y8da22RXbJW89A,97
|
|
13
|
+
okf_parser-0.1.0.dist-info/RECORD,,
|