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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ okf = okf.cli:app
@@ -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.