code2okf 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.
Files changed (48) hide show
  1. code2okf/SPEC.md +1006 -0
  2. code2okf/__init__.py +8 -0
  3. code2okf/cli.py +234 -0
  4. code2okf/clis/inspectmd/pyproject.toml +40 -0
  5. code2okf/clis/inspectmd/src/inspectmd/__init__.py +8 -0
  6. code2okf/clis/inspectmd/src/inspectmd/__main__.py +5 -0
  7. code2okf/clis/inspectmd/src/inspectmd/cli.py +159 -0
  8. code2okf/clis/inspectmd/src/inspectmd/parse.py +212 -0
  9. code2okf/clis/inspectokf/pyproject.toml +40 -0
  10. code2okf/clis/inspectokf/src/inspectokf/__init__.py +8 -0
  11. code2okf/clis/inspectokf/src/inspectokf/__main__.py +5 -0
  12. code2okf/clis/inspectokf/src/inspectokf/cli.py +104 -0
  13. code2okf/clis/merkleokf/pyproject.toml +40 -0
  14. code2okf/clis/merkleokf/src/merkleokf/__init__.py +8 -0
  15. code2okf/clis/merkleokf/src/merkleokf/__main__.py +5 -0
  16. code2okf/clis/merkleokf/src/merkleokf/cli.py +121 -0
  17. code2okf/clis/merkleokf/src/merkleokf/merkle.py +145 -0
  18. code2okf/clis/sizeokf/pyproject.toml +40 -0
  19. code2okf/clis/sizeokf/src/sizeokf/__init__.py +8 -0
  20. code2okf/clis/sizeokf/src/sizeokf/__main__.py +5 -0
  21. code2okf/clis/sizeokf/src/sizeokf/cli.py +93 -0
  22. code2okf/clis/sizeokf/src/sizeokf/sizes.py +155 -0
  23. code2okf/compile.py +267 -0
  24. code2okf/events.py +86 -0
  25. code2okf/kit/README.md +128 -0
  26. code2okf/kit/files/home/.local/lib/code2okf/mount-state.sh +48 -0
  27. code2okf/kit/files/home/.pi/agent/AGENTS.md +185 -0
  28. code2okf/kit/files/home/.pi/agent/models.json +84 -0
  29. code2okf/kit/files/home/.pi/agent/settings.json +7 -0
  30. code2okf/kit/files/home/.pi/agent/skills/compile-okf/SKILL.md +142 -0
  31. code2okf/kit/files/home/.pi/agent/skills/compile-okf/scripts/check-okf.sh +155 -0
  32. code2okf/kit/files/home/.pi/agent/skills/compile-okf/scripts/frontmatter-guard.py +289 -0
  33. code2okf/kit/files/home/.pi/agent/skills/curate-okf/SKILL.md +68 -0
  34. code2okf/kit/files/home/.pi/agent/skills/inspect-md/SKILL.md +52 -0
  35. code2okf/kit/files/home/.pi/agent/skills/inspect-okf/SKILL.md +47 -0
  36. code2okf/kit/files/home/.pi/agent/skills/merkle-okf/SKILL.md +59 -0
  37. code2okf/kit/files/home/.pi/agent/skills/size-okf/SKILL.md +52 -0
  38. code2okf/kit/spec.yaml +312 -0
  39. code2okf/resources.py +74 -0
  40. code2okf/sandbox.py +266 -0
  41. code2okf/workbench.py +572 -0
  42. code2okf-0.1.0.dist-info/METADATA +391 -0
  43. code2okf-0.1.0.dist-info/RECORD +48 -0
  44. code2okf-0.1.0.dist-info/WHEEL +4 -0
  45. code2okf-0.1.0.dist-info/entry_points.txt +2 -0
  46. code2okf-0.1.0.dist-info/licenses/LICENSE +21 -0
  47. code2okf-0.1.0.dist-info/licenses/LICENSE-OKF-SPEC.txt +203 -0
  48. code2okf-0.1.0.dist-info/licenses/NOTICE-OKF-SPEC.md +37 -0
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "inspectokf"
3
+ version = "0.1.0"
4
+ description = "Print a directory tree for an OKF wiki folder"
5
+ requires-python = ">=3.12"
6
+ dependencies = []
7
+
8
+ [project.scripts]
9
+ inspectokf = "inspectokf.cli:entrypoint"
10
+
11
+ [build-system]
12
+ requires = ["hatchling"]
13
+ build-backend = "hatchling.build"
14
+
15
+ [dependency-groups]
16
+ test = ["pytest>=8.4"]
17
+
18
+ # Paths are relative to this file, so they resolve wherever the project is
19
+ # invoked from. pythonpath keeps `import inspectokf` working without an install.
20
+ [tool.pytest.ini_options]
21
+ minversion = "8.0"
22
+ addopts = ["-ra", "--strict-markers", "--strict-config"]
23
+ testpaths = ["tests"]
24
+ pythonpath = ["src"]
25
+
26
+ [tool.ruff]
27
+ target-version = "py312"
28
+ line-length = 120
29
+
30
+ # Same rule set as inspectmd: owned here under the zero-overlap rule, not shared.
31
+ [tool.ruff.lint]
32
+ select = ["E", "W", "F", "I", "UP", "B", "SIM", "C4", "RET", "PT", "D"]
33
+
34
+ [tool.ruff.lint.pydocstyle]
35
+ convention = "google"
36
+
37
+ [tool.ruff.lint.per-file-ignores]
38
+ # A test's name is its documentation; a docstring on each of them would be noise.
39
+ # Fixtures and helpers are still expected to explain themselves.
40
+ "tests/*" = ["D100", "D103"]
@@ -0,0 +1,8 @@
1
+ """Inspect an OKF wiki directory tree (stdlib only)."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("inspectokf")
7
+ except PackageNotFoundError:
8
+ __version__ = "0.0.0+local"
@@ -0,0 +1,5 @@
1
+ """Allow `python -m inspectokf`."""
2
+
3
+ from inspectokf.cli import main
4
+
5
+ raise SystemExit(main())
@@ -0,0 +1,104 @@
1
+ """Command-line interface for inspectokf."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from inspectokf import __version__
12
+
13
+
14
+ def _build_parser() -> argparse.ArgumentParser:
15
+ parser = argparse.ArgumentParser(
16
+ prog="inspectokf",
17
+ description="Print a directory tree for an OKF wiki folder (via tree).",
18
+ )
19
+ parser.add_argument(
20
+ "path",
21
+ nargs="?",
22
+ type=Path,
23
+ default=Path("okf"),
24
+ help="wiki directory to show (default: okf)",
25
+ )
26
+ parser.add_argument(
27
+ "--version",
28
+ action="version",
29
+ version=f"%(prog)s {__version__}",
30
+ )
31
+ parser.add_argument(
32
+ "-L",
33
+ "--level",
34
+ type=int,
35
+ metavar="N",
36
+ help="descend at most N directory levels (default: unlimited)",
37
+ )
38
+ return parser
39
+
40
+
41
+ def _visibly_empty(path: Path) -> bool | None:
42
+ """Return True if ``path`` has no non-hidden children.
43
+
44
+ Returns ``None`` when the directory cannot be listed (caller should exit 2).
45
+ Dotfiles are ignored so a fresh ``okf/`` that only holds ``.DS_Store``
46
+ is treated as empty, matching ``tree``'s default of hiding names that start
47
+ with ``.``.
48
+ """
49
+ try:
50
+ return not any(not child.name.startswith(".") for child in path.iterdir())
51
+ except OSError as exc:
52
+ print(f"inspectokf: {exc}", file=sys.stderr)
53
+ return None
54
+
55
+
56
+ def main(argv: list[str] | None = None) -> int:
57
+ """Parse argv and run tree. Returns a process exit code."""
58
+ parser = _build_parser()
59
+ args = parser.parse_args(argv)
60
+
61
+ # Checked before the path and tree lookups so the message is the same
62
+ # wherever it is run from, and `tree` need not be installed to get it.
63
+ # tree rejects 0 itself; catching it here keeps the `inspectokf: ` prefix.
64
+ level: int | None = args.level
65
+ if level is not None and level < 1:
66
+ print(f"inspectokf: --level must be 1 or greater (got {level})", file=sys.stderr)
67
+ return 2
68
+
69
+ path: Path = args.path
70
+ if not path.is_dir():
71
+ print(f"inspectokf: not a directory: {path}", file=sys.stderr)
72
+ return 2
73
+
74
+ # Empty / dotfile-only dirs succeed without tree so a fresh wiki cannot fail
75
+ # on tree exit-code quirks or a missing binary.
76
+ empty = _visibly_empty(path)
77
+ if empty is None:
78
+ return 2
79
+ if empty:
80
+ sys.stdout.write(f"{path}\n\n0 directories, 0 files\n")
81
+ return 0
82
+
83
+ tree_bin = shutil.which("tree")
84
+ if tree_bin is None:
85
+ print("inspectokf: 'tree' not found on PATH", file=sys.stderr)
86
+ return 2
87
+
88
+ command = [tree_bin]
89
+ if level is not None:
90
+ command += ["-L", str(level)]
91
+ command.append(str(path))
92
+
93
+ # Inherit stdio: tree writes directly to this process's stdout/stderr.
94
+ try:
95
+ completed = subprocess.run(command, check=False) # noqa: S603
96
+ except OSError as exc:
97
+ print(f"inspectokf: {exc}", file=sys.stderr)
98
+ return 2
99
+ return 0 if completed.returncode == 0 else 2
100
+
101
+
102
+ def entrypoint() -> None:
103
+ """Console-script entry: exit with ``main``'s return code."""
104
+ raise SystemExit(main())
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "merkleokf"
3
+ version = "0.1.0"
4
+ description = "Print a Merkle hash tree for an OKF wiki folder"
5
+ requires-python = ">=3.12"
6
+ dependencies = []
7
+
8
+ [project.scripts]
9
+ merkleokf = "merkleokf.cli:entrypoint"
10
+
11
+ [build-system]
12
+ requires = ["hatchling"]
13
+ build-backend = "hatchling.build"
14
+
15
+ [dependency-groups]
16
+ test = ["pytest>=8.4"]
17
+
18
+ # Paths are relative to this file, so they resolve wherever the project is
19
+ # invoked from. pythonpath keeps `import merkleokf` working without an install.
20
+ [tool.pytest.ini_options]
21
+ minversion = "8.0"
22
+ addopts = ["-ra", "--strict-markers", "--strict-config"]
23
+ testpaths = ["tests"]
24
+ pythonpath = ["src"]
25
+
26
+ [tool.ruff]
27
+ target-version = "py312"
28
+ line-length = 120
29
+
30
+ # Same rule set as sizeokf: owned here under the zero-overlap rule, not shared.
31
+ [tool.ruff.lint]
32
+ select = ["E", "W", "F", "I", "UP", "B", "SIM", "C4", "RET", "PT", "D"]
33
+
34
+ [tool.ruff.lint.pydocstyle]
35
+ convention = "google"
36
+
37
+ [tool.ruff.lint.per-file-ignores]
38
+ # A test's name is its documentation; a docstring on each of them would be noise.
39
+ # Fixtures and helpers are still expected to explain themselves.
40
+ "tests/*" = ["D100", "D103"]
@@ -0,0 +1,8 @@
1
+ """Print a Merkle hash tree for an OKF wiki folder (stdlib only)."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("merkleokf")
7
+ except PackageNotFoundError:
8
+ __version__ = "0.0.0+local"
@@ -0,0 +1,5 @@
1
+ """Allow `python -m merkleokf`."""
2
+
3
+ from merkleokf.cli import main
4
+
5
+ raise SystemExit(main())
@@ -0,0 +1,121 @@
1
+ """Command-line interface for merkleokf."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from collections.abc import Sequence
8
+ from pathlib import Path
9
+
10
+ from merkleokf import __version__
11
+ from merkleokf.merkle import Entry, collect, hash_file, short
12
+
13
+
14
+ def escape_display_path(path: str) -> str:
15
+ """Escape controls so a path stays one table cell (no newlines or ANSI)."""
16
+ out: list[str] = []
17
+ for ch in path:
18
+ code = ord(ch)
19
+ if ch == "\\":
20
+ out.append("\\\\")
21
+ elif ch == "\n":
22
+ out.append("\\n")
23
+ elif ch == "\r":
24
+ out.append("\\r")
25
+ elif ch == "\t":
26
+ out.append("\\t")
27
+ elif code < 0x20 or code == 0x7F:
28
+ out.append(f"\\x{code:02x}")
29
+ else:
30
+ out.append(ch)
31
+ return "".join(out)
32
+
33
+
34
+ def format_table(entries: Sequence[Entry]) -> str:
35
+ """Render entries as a fixed-width table, one row per file or directory."""
36
+ if not entries:
37
+ return "(no Markdown files)\n"
38
+
39
+ headers = ("Hash", "Files", "Path")
40
+ rows = [(short(e.digest), f"{e.files:,}", escape_display_path(e.path)) for e in entries]
41
+
42
+ widths = [len(h) for h in headers]
43
+ for row in rows:
44
+ for i, cell in enumerate(row):
45
+ widths[i] = max(widths[i], len(cell))
46
+
47
+ def fmt(cells: tuple[str, ...]) -> str:
48
+ # Only the count is right-aligned; hashes are fixed width already.
49
+ return " ".join(
50
+ cell.rjust(widths[i]) if i == 1 else cell.ljust(widths[i]) for i, cell in enumerate(cells)
51
+ ).rstrip()
52
+
53
+ lines = [fmt(headers), fmt(tuple("-" * w for w in widths))]
54
+ lines.extend(fmt(row) for row in rows)
55
+ return "\n".join(lines) + "\n"
56
+
57
+
58
+ def _build_parser() -> argparse.ArgumentParser:
59
+ parser = argparse.ArgumentParser(
60
+ prog="merkleokf",
61
+ description="Print a Merkle hash tree for an OKF wiki folder, or the hash of one file.",
62
+ )
63
+ parser.add_argument(
64
+ "path",
65
+ nargs="?",
66
+ type=Path,
67
+ default=Path("okf"),
68
+ help="wiki directory or Markdown file to hash (default: okf)",
69
+ )
70
+ parser.add_argument(
71
+ "--version",
72
+ action="version",
73
+ version=f"%(prog)s {__version__}",
74
+ )
75
+ parser.add_argument(
76
+ "-L",
77
+ "--level",
78
+ type=int,
79
+ metavar="N",
80
+ help=(
81
+ "list entries at most N directory levels deep "
82
+ "(default: unlimited; 0 = walk root only; ignored for a file)"
83
+ ),
84
+ )
85
+ parser.add_argument(
86
+ "--nolog",
87
+ action="store_true",
88
+ help="ignore the walk root's log.md (omit from listing and digests; ignored for a file)",
89
+ )
90
+ return parser
91
+
92
+
93
+ def main(argv: list[str] | None = None) -> int:
94
+ """Parse argv and print the Merkle tree. Returns a process exit code."""
95
+ parser = _build_parser()
96
+ args = parser.parse_args(argv)
97
+
98
+ # Checked before the path so the message is the same wherever it is run from.
99
+ level: int | None = args.level
100
+ if level is not None and level < 0:
101
+ print(f"merkleokf: --level must be 0 or greater (got {level})", file=sys.stderr)
102
+ return 2
103
+
104
+ path: Path = args.path
105
+
106
+ if path.is_file():
107
+ sys.stdout.write(f"{short(hash_file(path))} {escape_display_path(path.name)}\n")
108
+ return 0
109
+
110
+ if not path.is_dir():
111
+ print(f"merkleokf: not a file or directory: {path}", file=sys.stderr)
112
+ return 2
113
+
114
+ entries, _ = collect(path, max_level=level, nolog=args.nolog)
115
+ sys.stdout.write(format_table(entries))
116
+ return 0
117
+
118
+
119
+ def entrypoint() -> None:
120
+ """Console-script entry: exit with ``main``'s return code."""
121
+ raise SystemExit(main())
@@ -0,0 +1,145 @@
1
+ """Build a Merkle hash tree over an OKF wiki.
2
+
3
+ Every ``*.md`` file is hashed over its **raw bytes**, and every directory over
4
+ its children's digests, so a change to any leaf propagates to exactly one chain
5
+ of parent directories and nowhere else. That is what makes change localisation
6
+ cheap: compare one root hash, and if it moved, descend into the single subtree
7
+ whose hash also moved.
8
+
9
+ Raw bytes, not frontmatter-stripped content: this is the integrity tool, so a
10
+ timestamp bump counts as a change. ``sizeokf`` is the one that measures prose.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import sys
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+
20
+ DISPLAY_WIDTH = 12
21
+ """Hex characters shown per digest. 48 bits — collision odds ~8e-11 at 217 files,
22
+ ~2e-7 at 10,000. Full digests are always computed; only the display truncates."""
23
+
24
+
25
+ def hash_file(path: Path) -> bytes:
26
+ """SHA-256 over a file's raw bytes.
27
+
28
+ An unreadable file is reported on stderr and contributes an all-zero digest
29
+ rather than aborting the walk — one bad page must not cost the rest.
30
+ """
31
+ try:
32
+ return hashlib.sha256(path.read_bytes()).digest()
33
+ except OSError as exc:
34
+ print(f"merkleokf: skipping {path}: {exc}", file=sys.stderr)
35
+ return bytes(32)
36
+
37
+
38
+ def short(digest: bytes) -> str:
39
+ """Truncate a digest to its displayed hex prefix."""
40
+ return digest.hex()[:DISPLAY_WIDTH]
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Entry:
45
+ """One listed file or directory, with its digest."""
46
+
47
+ path: str
48
+ """Display path, rooted at the walk target's name. Directories end in ``/``."""
49
+
50
+ is_dir: bool
51
+ digest: bytes
52
+ """Full SHA-256. For a directory, the Merkle digest over its children."""
53
+
54
+ files: int
55
+ """Markdown files covered. Always 1 for a file."""
56
+
57
+ depth: int
58
+ """1 for entries directly inside the root; 0 for the root itself."""
59
+
60
+
61
+ def collect(
62
+ root: Path, *, max_level: int | None = None, nolog: bool = False
63
+ ) -> tuple[list[Entry], Entry]:
64
+ """Walk ``root``, returning ``(listed_entries, root_entry)``.
65
+
66
+ ``listed_entries`` always includes ``root_entry``. Directory digests always
67
+ cover the full subtree regardless of ``max_level``; the level only decides
68
+ which non-root entries get listed. ``max_level=0`` lists only the walk root;
69
+ ``max_level=1`` lists the entries directly inside ``root``, matching
70
+ ``inspectokf -L 1``.
71
+
72
+ When ``nolog`` is true, the walk root's own ``log.md`` is omitted entirely
73
+ (not listed and not mixed into digests), whatever the root directory is
74
+ named. Nested ``log.md`` files, and ``log.md`` under any other root, are
75
+ still hashed.
76
+ """
77
+ entries: list[Entry] = []
78
+ prefix = f"{root.name}/"
79
+
80
+ def walk(directory: Path, depth: int) -> tuple[bytes, int]:
81
+ """Return ``(digest, files)`` for ``directory``, recording listed entries."""
82
+ # Sorted so the digest is reproducible across filesystems, which do not
83
+ # agree on readdir order.
84
+ children = sorted(directory.iterdir(), key=lambda p: p.name)
85
+ acc = hashlib.sha256()
86
+ files = 0
87
+
88
+ for child in children:
89
+ if child.name.startswith("."):
90
+ continue
91
+ # is_dir() follows symlinks; is_symlink() first keeps cycles out.
92
+ if child.is_symlink():
93
+ continue
94
+
95
+ if child.is_dir():
96
+ digest, child_files = walk(child, depth + 1)
97
+ files += child_files
98
+ # The type tag keeps a file and a directory of the same name
99
+ # apart; the name keeps a pure rename from being invisible.
100
+ acc.update(b"d" + child.name.encode("utf-8") + digest)
101
+ listed = Entry(
102
+ path=f"{prefix}{child.relative_to(root)}/",
103
+ is_dir=True,
104
+ digest=digest,
105
+ files=child_files,
106
+ depth=depth,
107
+ )
108
+ elif child.suffix == ".md":
109
+ # Anchored to the walk root itself, not to any directory named
110
+ # "okf": a nested okf/ keeps its own log.md, as the docstring
111
+ # promises, and the walk root's log.md is skipped whatever the
112
+ # root directory happens to be called.
113
+ if nolog and child.name == "log.md" and child.parent == root:
114
+ continue
115
+ digest = hash_file(child)
116
+ files += 1
117
+ acc.update(b"f" + child.name.encode("utf-8") + digest)
118
+ listed = Entry(
119
+ path=f"{prefix}{child.relative_to(root)}",
120
+ is_dir=False,
121
+ digest=digest,
122
+ files=1,
123
+ depth=depth,
124
+ )
125
+ else:
126
+ continue
127
+
128
+ if max_level is None or depth <= max_level:
129
+ entries.append(listed)
130
+
131
+ return acc.digest(), files
132
+
133
+ root_digest, root_files = walk(root, 1)
134
+ root_entry = Entry(
135
+ path=prefix,
136
+ is_dir=True,
137
+ digest=root_digest,
138
+ files=root_files,
139
+ depth=0,
140
+ )
141
+ entries.append(root_entry)
142
+
143
+ # Alphabetical, so two runs diff line by line — the whole point of hashing.
144
+ entries.sort(key=lambda e: e.path)
145
+ return entries, root_entry
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "sizeokf"
3
+ version = "0.1.0"
4
+ description = "Report content word counts for an OKF wiki folder"
5
+ requires-python = ">=3.12"
6
+ dependencies = []
7
+
8
+ [project.scripts]
9
+ sizeokf = "sizeokf.cli:entrypoint"
10
+
11
+ [build-system]
12
+ requires = ["hatchling"]
13
+ build-backend = "hatchling.build"
14
+
15
+ [dependency-groups]
16
+ test = ["pytest>=8.4"]
17
+
18
+ # Paths are relative to this file, so they resolve wherever the project is
19
+ # invoked from. pythonpath keeps `import sizeokf` working without an install.
20
+ [tool.pytest.ini_options]
21
+ minversion = "8.0"
22
+ addopts = ["-ra", "--strict-markers", "--strict-config"]
23
+ testpaths = ["tests"]
24
+ pythonpath = ["src"]
25
+
26
+ [tool.ruff]
27
+ target-version = "py312"
28
+ line-length = 120
29
+
30
+ # Same rule set as inspectokf: owned here under the zero-overlap rule, not shared.
31
+ [tool.ruff.lint]
32
+ select = ["E", "W", "F", "I", "UP", "B", "SIM", "C4", "RET", "PT", "D"]
33
+
34
+ [tool.ruff.lint.pydocstyle]
35
+ convention = "google"
36
+
37
+ [tool.ruff.lint.per-file-ignores]
38
+ # A test's name is its documentation; a docstring on each of them would be noise.
39
+ # Fixtures and helpers are still expected to explain themselves.
40
+ "tests/*" = ["D100", "D103"]
@@ -0,0 +1,8 @@
1
+ """Report sizes for an OKF wiki folder (stdlib only)."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("sizeokf")
7
+ except PackageNotFoundError:
8
+ __version__ = "0.0.0+local"
@@ -0,0 +1,5 @@
1
+ """Allow `python -m sizeokf`."""
2
+
3
+ from sizeokf.cli import main
4
+
5
+ raise SystemExit(main())
@@ -0,0 +1,93 @@
1
+ """Command-line interface for sizeokf."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from collections.abc import Sequence
8
+ from pathlib import Path
9
+
10
+ from sizeokf import __version__
11
+ from sizeokf.sizes import Entry, collect
12
+
13
+
14
+ def format_table(entries: Sequence[Entry]) -> str:
15
+ """Render entries as a fixed-width table, one row per file or directory."""
16
+ if not entries:
17
+ return "(no Markdown files)\n"
18
+
19
+ headers = ("Words", "Files", "Path")
20
+ rows = [(f"{e.words:,}", f"{e.files:,}", e.path) for e in entries]
21
+
22
+ widths = [len(h) for h in headers]
23
+ for row in rows:
24
+ for i, cell in enumerate(row):
25
+ widths[i] = max(widths[i], len(cell))
26
+
27
+ def fmt(cells: tuple[str, ...]) -> str:
28
+ # Counts right-aligned so magnitudes line up; the path column trails.
29
+ return " ".join(
30
+ cell.rjust(widths[i]) if i < 2 else cell.ljust(widths[i]) for i, cell in enumerate(cells)
31
+ ).rstrip()
32
+
33
+ lines = [fmt(headers), fmt(tuple("-" * w for w in widths))]
34
+ lines.extend(fmt(row) for row in rows)
35
+ return "\n".join(lines) + "\n"
36
+
37
+
38
+ def _build_parser() -> argparse.ArgumentParser:
39
+ parser = argparse.ArgumentParser(
40
+ prog="sizeokf",
41
+ description="Report Markdown content word counts for an OKF wiki folder, excluding YAML frontmatter.",
42
+ )
43
+ parser.add_argument(
44
+ "path",
45
+ nargs="?",
46
+ type=Path,
47
+ default=Path("okf"),
48
+ help="wiki directory to measure (default: okf)",
49
+ )
50
+ parser.add_argument(
51
+ "--version",
52
+ action="version",
53
+ version=f"%(prog)s {__version__}",
54
+ )
55
+ parser.add_argument(
56
+ "-L",
57
+ "--level",
58
+ type=int,
59
+ metavar="N",
60
+ help="list entries at most N directory levels deep (default: unlimited; 0 = walk root only)",
61
+ )
62
+ parser.add_argument(
63
+ "--nolog",
64
+ action="store_true",
65
+ help="ignore okf/log.md (omit from listing and totals)",
66
+ )
67
+ return parser
68
+
69
+
70
+ def main(argv: list[str] | None = None) -> int:
71
+ """Parse argv and print the size table. Returns a process exit code."""
72
+ parser = _build_parser()
73
+ args = parser.parse_args(argv)
74
+
75
+ # Checked before the path so the message is the same wherever it is run from.
76
+ level: int | None = args.level
77
+ if level is not None and level < 0:
78
+ print(f"sizeokf: --level must be 0 or greater (got {level})", file=sys.stderr)
79
+ return 2
80
+
81
+ path: Path = args.path
82
+ if not path.is_dir():
83
+ print(f"sizeokf: not a directory: {path}", file=sys.stderr)
84
+ return 2
85
+
86
+ entries, _ = collect(path, max_level=level, nolog=args.nolog)
87
+ sys.stdout.write(format_table(entries))
88
+ return 0
89
+
90
+
91
+ def entrypoint() -> None:
92
+ """Console-script entry: exit with ``main``'s return code."""
93
+ raise SystemExit(main())