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 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()
@@ -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
+ }
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ okf-parser = okf_parser.cli:main
3
+ okf-parser-mcp = okf_parser.cli:run_mcp_stdio