okf-cli 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/__init__.py +0 -0
- okf/cli.py +226 -0
- okf_cli-0.1.0.dist-info/METADATA +144 -0
- okf_cli-0.1.0.dist-info/RECORD +7 -0
- okf_cli-0.1.0.dist-info/WHEEL +4 -0
- okf_cli-0.1.0.dist-info/entry_points.txt +2 -0
- okf_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
okf/__init__.py
ADDED
|
File without changes
|
okf/cli.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""okf enrich — Plain markdown to OKF bundle converter."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(
|
|
12
|
+
name="okf",
|
|
13
|
+
help="Open Knowledge Format tooling",
|
|
14
|
+
no_args_is_help=True,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
RESERVED = frozenset({"index.md", "log.md", "readme.md"})
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _yaml_val(v: str) -> str:
|
|
21
|
+
"""Format a string value as valid YAML via JSON encoding."""
|
|
22
|
+
return json.dumps(v, ensure_ascii=True)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _build_frontmatter(type_: str, title: str, description: str, timestamp: str) -> str:
|
|
26
|
+
parts = [
|
|
27
|
+
"---",
|
|
28
|
+
f"type: {_yaml_val(type_)}",
|
|
29
|
+
f"title: {_yaml_val(title)}",
|
|
30
|
+
f"description: {_yaml_val(description)}",
|
|
31
|
+
]
|
|
32
|
+
if timestamp:
|
|
33
|
+
parts.append(f"timestamp: {_yaml_val(timestamp)}")
|
|
34
|
+
parts.append("---")
|
|
35
|
+
return "\n".join(parts)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _parse_md(text: str) -> tuple[str, str, str]:
|
|
39
|
+
"""Parse title, description, body from plain markdown.
|
|
40
|
+
|
|
41
|
+
Returns (title, description, body).
|
|
42
|
+
Raises ValueError on format violation.
|
|
43
|
+
"""
|
|
44
|
+
lines = text.splitlines(keepends=True)
|
|
45
|
+
|
|
46
|
+
if not lines or not lines[0].startswith("# "):
|
|
47
|
+
raise ValueError("Line 1 must be '# Title'")
|
|
48
|
+
|
|
49
|
+
title = lines[0][2:].strip()
|
|
50
|
+
if not title:
|
|
51
|
+
raise ValueError("Title cannot be empty")
|
|
52
|
+
|
|
53
|
+
# Find first non-blank after title line
|
|
54
|
+
i = 1
|
|
55
|
+
while i < len(lines) and not lines[i].strip():
|
|
56
|
+
i += 1
|
|
57
|
+
|
|
58
|
+
# Collect consecutive > lines
|
|
59
|
+
desc_lines = []
|
|
60
|
+
while i < len(lines) and lines[i].startswith(">"):
|
|
61
|
+
# Handle both "> text" and ">text"
|
|
62
|
+
content = lines[i][1:].strip()
|
|
63
|
+
desc_lines.append(content)
|
|
64
|
+
i += 1
|
|
65
|
+
|
|
66
|
+
if not desc_lines:
|
|
67
|
+
raise ValueError("Must have a '> description' block after title")
|
|
68
|
+
|
|
69
|
+
description = " ".join(desc_lines).strip()
|
|
70
|
+
|
|
71
|
+
# Skip blank lines between description and body
|
|
72
|
+
while i < len(lines) and not lines[i].strip():
|
|
73
|
+
i += 1
|
|
74
|
+
|
|
75
|
+
body = "".join(lines[i:])
|
|
76
|
+
|
|
77
|
+
return title, description, body
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@app.command()
|
|
81
|
+
def enrich(
|
|
82
|
+
input_dir: str = typer.Argument(
|
|
83
|
+
..., help="Source directory of plain markdown files"
|
|
84
|
+
),
|
|
85
|
+
output_dir: str = typer.Argument(
|
|
86
|
+
"bundled", help="Output directory for the OKF bundle (default: bundled)"
|
|
87
|
+
),
|
|
88
|
+
default_type: str = typer.Option(
|
|
89
|
+
None, help="Type for root-level files (skip root files if omitted)"
|
|
90
|
+
),
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Convert plain markdown into an OKF-conformant knowledge bundle.
|
|
93
|
+
|
|
94
|
+
Each .md file must start with '# Title' followed by a '>' description block.
|
|
95
|
+
Directory name determines the concept 'type'. Root-level files need --default-type.
|
|
96
|
+
If output-dir is omitted, defaults to 'bundled'.
|
|
97
|
+
"""
|
|
98
|
+
src = Path(input_dir)
|
|
99
|
+
dst = Path(output_dir)
|
|
100
|
+
|
|
101
|
+
if not src.is_dir():
|
|
102
|
+
typer.echo(f"Error: input directory '{input_dir}' not found", err=True)
|
|
103
|
+
raise typer.Exit(code=1)
|
|
104
|
+
|
|
105
|
+
if dst.exists():
|
|
106
|
+
shutil.rmtree(dst)
|
|
107
|
+
typer.echo(f"Removed existing '{output_dir}'", err=True)
|
|
108
|
+
|
|
109
|
+
# Collect all .md files (skip reserved names, warn for reserved)
|
|
110
|
+
md_files = []
|
|
111
|
+
for f in sorted(src.rglob("*.md")):
|
|
112
|
+
lower = f.name.lower()
|
|
113
|
+
if lower in RESERVED:
|
|
114
|
+
if lower == "log.md":
|
|
115
|
+
typer.echo(
|
|
116
|
+
f"Warning: Skipping {f.relative_to(src)} — reserved filename '{f.name}' (not a concept)",
|
|
117
|
+
err=True,
|
|
118
|
+
)
|
|
119
|
+
continue
|
|
120
|
+
md_files.append(f)
|
|
121
|
+
if not md_files:
|
|
122
|
+
typer.echo("No markdown files found (excluding index.md, log.md)", err=True)
|
|
123
|
+
raise typer.Exit(code=1)
|
|
124
|
+
|
|
125
|
+
# Process each file, storing metadata for index generation
|
|
126
|
+
processed: list[tuple[Path, dict]] = [] # (relative_output_path, metadata)
|
|
127
|
+
|
|
128
|
+
for f in md_files:
|
|
129
|
+
rel = f.relative_to(src)
|
|
130
|
+
out_file = dst / rel
|
|
131
|
+
out_file.parent.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
|
|
133
|
+
# Determine type
|
|
134
|
+
if rel.parent == Path("."):
|
|
135
|
+
if default_type is None:
|
|
136
|
+
typer.echo(
|
|
137
|
+
f"Warning: Skipping {rel} — root-level file needs --default-type",
|
|
138
|
+
err=True,
|
|
139
|
+
)
|
|
140
|
+
continue
|
|
141
|
+
type_name = default_type
|
|
142
|
+
else:
|
|
143
|
+
type_name = rel.parent.name
|
|
144
|
+
|
|
145
|
+
# Parse
|
|
146
|
+
try:
|
|
147
|
+
text = f.read_text(encoding="utf-8")
|
|
148
|
+
title, description, body = _parse_md(text)
|
|
149
|
+
except ValueError as e:
|
|
150
|
+
typer.echo(f"Error: {rel}: {e}", err=True)
|
|
151
|
+
raise typer.Exit(code=1)
|
|
152
|
+
|
|
153
|
+
# Timestamp from file mtime
|
|
154
|
+
mtime = os.path.getmtime(f)
|
|
155
|
+
ts = datetime.fromtimestamp(mtime, tz=timezone.utc).isoformat()
|
|
156
|
+
|
|
157
|
+
# Build and write
|
|
158
|
+
frontmatter = _build_frontmatter(type_name, title, description, ts)
|
|
159
|
+
|
|
160
|
+
# Validate frontmatter structure
|
|
161
|
+
if not (frontmatter.startswith("---\n") and frontmatter.endswith("\n---")):
|
|
162
|
+
typer.echo(f"Error: {rel}: generated invalid frontmatter", err=True)
|
|
163
|
+
raise typer.Exit(code=1)
|
|
164
|
+
|
|
165
|
+
# Preserve original line endings? Keep as-is from input.
|
|
166
|
+
out_file.write_text(f"{frontmatter}\n\n{body}", encoding="utf-8")
|
|
167
|
+
|
|
168
|
+
processed.append(
|
|
169
|
+
(rel, {"title": title, "description": description, "type": type_name})
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# Collect per-directory files and subdirectories
|
|
173
|
+
dir_files: dict[Path, list[dict]] = {}
|
|
174
|
+
dir_subdirs: dict[Path, set[str]] = {}
|
|
175
|
+
|
|
176
|
+
for rel, meta in processed:
|
|
177
|
+
parent = rel.parent
|
|
178
|
+
dir_files.setdefault(parent, []).append(
|
|
179
|
+
{
|
|
180
|
+
"title": meta["title"],
|
|
181
|
+
"description": meta["description"],
|
|
182
|
+
"path": rel.name,
|
|
183
|
+
}
|
|
184
|
+
)
|
|
185
|
+
# Record subdirectory relationships at every level
|
|
186
|
+
for i in range(len(rel.parts) - 1):
|
|
187
|
+
grandparent = Path(*rel.parts[:i]) if i > 0 else Path(".")
|
|
188
|
+
child_dir = rel.parts[i]
|
|
189
|
+
dir_subdirs.setdefault(grandparent, set()).add(child_dir)
|
|
190
|
+
|
|
191
|
+
# Generate index.md for every directory with files or subdirs
|
|
192
|
+
for dir_path in sorted(set(dir_files.keys()) | set(dir_subdirs.keys())):
|
|
193
|
+
index_path = dst / dir_path / "index.md"
|
|
194
|
+
if index_path.exists():
|
|
195
|
+
continue
|
|
196
|
+
|
|
197
|
+
lines = []
|
|
198
|
+
entries = dir_files.get(dir_path, [])
|
|
199
|
+
subdirs = sorted(dir_subdirs.get(dir_path, set()))
|
|
200
|
+
|
|
201
|
+
if entries:
|
|
202
|
+
lines.append("# Contents")
|
|
203
|
+
lines.append("")
|
|
204
|
+
for e in entries:
|
|
205
|
+
desc = f" - {e['description']}" if e.get("description") else ""
|
|
206
|
+
lines.append(f"* [{e['title']}]({e['path']}){desc}")
|
|
207
|
+
lines.append("")
|
|
208
|
+
|
|
209
|
+
if subdirs:
|
|
210
|
+
lines.append("# Directories")
|
|
211
|
+
lines.append("")
|
|
212
|
+
for d in subdirs:
|
|
213
|
+
lines.append(f"* [{d}]({d}/)")
|
|
214
|
+
lines.append("")
|
|
215
|
+
|
|
216
|
+
if lines:
|
|
217
|
+
index_path.parent.mkdir(parents=True, exist_ok=True)
|
|
218
|
+
index_path.write_text("\n".join(lines), encoding="utf-8")
|
|
219
|
+
|
|
220
|
+
# Summary
|
|
221
|
+
n = len(processed)
|
|
222
|
+
typer.echo(f"Done. Converted {n} file{'s' if n != 1 else ''} → {dst}")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
if __name__ == "__main__":
|
|
226
|
+
app()
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: okf-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Open Knowledge Format tooling
|
|
5
|
+
License: MIT License
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2026 Dheerapat Tookkane
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
10
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
11
|
+
in the Software without restriction, including without limitation the rights
|
|
12
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
14
|
+
furnished to do so, subject to the following conditions:
|
|
15
|
+
|
|
16
|
+
The above copyright notice and this permission notice shall be included in all
|
|
17
|
+
copies or substantial portions of the Software.
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
21
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
22
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
23
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
24
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
25
|
+
SOFTWARE.
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Requires-Python: >=3.13
|
|
28
|
+
Requires-Dist: typer>=0.15
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# okf-cli — Open Knowledge Format tooling
|
|
32
|
+
|
|
33
|
+
Converts plain markdown into [OKF](https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md)-conformant knowledge bundles. Domain experts write `# Title` then `> description` — `okf enrich` generates frontmatter, type, timestamps, and index files.
|
|
34
|
+
|
|
35
|
+
## Quickstart
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
uv sync
|
|
39
|
+
uv run okf example # output → bundled/
|
|
40
|
+
cat bundled/index.md
|
|
41
|
+
cat bundled/tables/orders.md
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
okf <input-dir> [output-dir] [--default-type <name>]
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
| Argument | Description |
|
|
51
|
+
|---|---|
|
|
52
|
+
| `input-dir` | Directory of plain `.md` files |
|
|
53
|
+
| `output-dir` | Target directory (default: `bundled`) |
|
|
54
|
+
| `--default-type` | Type for root-level files (skip root files if omitted) |
|
|
55
|
+
|
|
56
|
+
## Writing input files
|
|
57
|
+
|
|
58
|
+
Every `.md` file must start with:
|
|
59
|
+
|
|
60
|
+
```markdown
|
|
61
|
+
# Title
|
|
62
|
+
|
|
63
|
+
> Description
|
|
64
|
+
> Second optional description line.
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Everything after the description block is free-form — preserved unchanged.
|
|
68
|
+
|
|
69
|
+
**Rules:**
|
|
70
|
+
|
|
71
|
+
| Rule | Why |
|
|
72
|
+
|---|---|
|
|
73
|
+
| First line must be `# Title` | Tool reads title here |
|
|
74
|
+
| Followed by `> Description` | Tool reads description here |
|
|
75
|
+
| Folder name = concept type | `tables/orders.md` → `type: "tables"` |
|
|
76
|
+
| Only `.md` files processed | Non-`.md` files ignored |
|
|
77
|
+
| Reserved names skipped: `index.md`, `log.md`, `README.md` | OKF spec reserves these |
|
|
78
|
+
|
|
79
|
+
**Violations:**
|
|
80
|
+
|
|
81
|
+
| Condition | Behavior |
|
|
82
|
+
|---|---|
|
|
83
|
+
| Line 1 not `# Title` | Error, stop |
|
|
84
|
+
| Empty title | Error, stop |
|
|
85
|
+
| No `> description` block | Error, stop |
|
|
86
|
+
| Root-level file without `--default-type` | Skip file, warn, continue |
|
|
87
|
+
|
|
88
|
+
Root files need `--default-type`. Otherwise put files in named folders.
|
|
89
|
+
|
|
90
|
+
See the [`example/`](example/) directory for a sample of how to structure files.
|
|
91
|
+
|
|
92
|
+
## How it works
|
|
93
|
+
|
|
94
|
+
1. Walk `input-dir` for `.md` files (skip reserved names)
|
|
95
|
+
2. Extract `title` from `#`, `description` from `>`
|
|
96
|
+
3. Set `type` from parent dir name, `timestamp` from file mtime
|
|
97
|
+
4. Write concept files with YAML frontmatter
|
|
98
|
+
5. Generate `index.md` per directory — `# Contents` for files, `# Directories` for subdirs (recursive)
|
|
99
|
+
|
|
100
|
+
## Output
|
|
101
|
+
|
|
102
|
+
Each concept becomes a markdown file with YAML frontmatter:
|
|
103
|
+
|
|
104
|
+
```yaml
|
|
105
|
+
---
|
|
106
|
+
type: "tables"
|
|
107
|
+
title: "Customer Orders"
|
|
108
|
+
description: "One row per completed customer order across all channels."
|
|
109
|
+
timestamp: "2026-07-04T15:06:51+00:00"
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
Original body preserved as-is.
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Every directory gets its own `index.md`:
|
|
116
|
+
|
|
117
|
+
```markdown
|
|
118
|
+
# Contents
|
|
119
|
+
|
|
120
|
+
* [Customer Orders](orders.md) - One row per completed customer order across all channels.
|
|
121
|
+
|
|
122
|
+
# Directories
|
|
123
|
+
|
|
124
|
+
* [partitions](partitions/)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## OKF Conformance
|
|
128
|
+
|
|
129
|
+
Generated bundles conform to [OKF v0.1](OKF_SPEC.md) (§9):
|
|
130
|
+
|
|
131
|
+
- Every non-reserved `.md` has parseable YAML frontmatter with non-empty `type` ✓
|
|
132
|
+
- Reserved filenames follow spec structure ✓
|
|
133
|
+
- Consumers MUST tolerate missing optional fields, unknown types, broken links ✓
|
|
134
|
+
|
|
135
|
+
## Project layout
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
okf-cli
|
|
139
|
+
├── OKF_SPEC.md # OKF specification
|
|
140
|
+
├── pyproject.toml # uv-managed Python project
|
|
141
|
+
├── src/okf/cli.py # Single-file CLI
|
|
142
|
+
├── tests/test_cli.py # Tests
|
|
143
|
+
└── example/ # Sample input markdown
|
|
144
|
+
```
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
okf/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
okf/cli.py,sha256=5AHdKBCcbc9g1d7M85hLq7QyErZubJZ_B_I1nC8zpW8,7158
|
|
3
|
+
okf_cli-0.1.0.dist-info/METADATA,sha256=m9szofWSVyHBsjzXONxWHtzmzPoNPvnFwGVFHPOt4_I,4595
|
|
4
|
+
okf_cli-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
5
|
+
okf_cli-0.1.0.dist-info/entry_points.txt,sha256=-FFFP-_REkdgRZ0-8urVv26KWHk-pTVbm3KRLjHTdV0,36
|
|
6
|
+
okf_cli-0.1.0.dist-info/licenses/LICENSE,sha256=K7prhl8CGtmtTAoVj_Pcvmq-Z2RzJ9AYvbmdgfLQH3Y,1075
|
|
7
|
+
okf_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dheerapat Tookkane
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|