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.
- code2okf/SPEC.md +1006 -0
- code2okf/__init__.py +8 -0
- code2okf/cli.py +234 -0
- code2okf/clis/inspectmd/pyproject.toml +40 -0
- code2okf/clis/inspectmd/src/inspectmd/__init__.py +8 -0
- code2okf/clis/inspectmd/src/inspectmd/__main__.py +5 -0
- code2okf/clis/inspectmd/src/inspectmd/cli.py +159 -0
- code2okf/clis/inspectmd/src/inspectmd/parse.py +212 -0
- code2okf/clis/inspectokf/pyproject.toml +40 -0
- code2okf/clis/inspectokf/src/inspectokf/__init__.py +8 -0
- code2okf/clis/inspectokf/src/inspectokf/__main__.py +5 -0
- code2okf/clis/inspectokf/src/inspectokf/cli.py +104 -0
- code2okf/clis/merkleokf/pyproject.toml +40 -0
- code2okf/clis/merkleokf/src/merkleokf/__init__.py +8 -0
- code2okf/clis/merkleokf/src/merkleokf/__main__.py +5 -0
- code2okf/clis/merkleokf/src/merkleokf/cli.py +121 -0
- code2okf/clis/merkleokf/src/merkleokf/merkle.py +145 -0
- code2okf/clis/sizeokf/pyproject.toml +40 -0
- code2okf/clis/sizeokf/src/sizeokf/__init__.py +8 -0
- code2okf/clis/sizeokf/src/sizeokf/__main__.py +5 -0
- code2okf/clis/sizeokf/src/sizeokf/cli.py +93 -0
- code2okf/clis/sizeokf/src/sizeokf/sizes.py +155 -0
- code2okf/compile.py +267 -0
- code2okf/events.py +86 -0
- code2okf/kit/README.md +128 -0
- code2okf/kit/files/home/.local/lib/code2okf/mount-state.sh +48 -0
- code2okf/kit/files/home/.pi/agent/AGENTS.md +185 -0
- code2okf/kit/files/home/.pi/agent/models.json +84 -0
- code2okf/kit/files/home/.pi/agent/settings.json +7 -0
- code2okf/kit/files/home/.pi/agent/skills/compile-okf/SKILL.md +142 -0
- code2okf/kit/files/home/.pi/agent/skills/compile-okf/scripts/check-okf.sh +155 -0
- code2okf/kit/files/home/.pi/agent/skills/compile-okf/scripts/frontmatter-guard.py +289 -0
- code2okf/kit/files/home/.pi/agent/skills/curate-okf/SKILL.md +68 -0
- code2okf/kit/files/home/.pi/agent/skills/inspect-md/SKILL.md +52 -0
- code2okf/kit/files/home/.pi/agent/skills/inspect-okf/SKILL.md +47 -0
- code2okf/kit/files/home/.pi/agent/skills/merkle-okf/SKILL.md +59 -0
- code2okf/kit/files/home/.pi/agent/skills/size-okf/SKILL.md +52 -0
- code2okf/kit/spec.yaml +312 -0
- code2okf/resources.py +74 -0
- code2okf/sandbox.py +266 -0
- code2okf/workbench.py +572 -0
- code2okf-0.1.0.dist-info/METADATA +391 -0
- code2okf-0.1.0.dist-info/RECORD +48 -0
- code2okf-0.1.0.dist-info/WHEEL +4 -0
- code2okf-0.1.0.dist-info/entry_points.txt +2 -0
- code2okf-0.1.0.dist-info/licenses/LICENSE +21 -0
- code2okf-0.1.0.dist-info/licenses/LICENSE-OKF-SPEC.txt +203 -0
- 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,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,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,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())
|