blockspec 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.
- blockspec/__init__.py +10 -0
- blockspec/__main__.py +4 -0
- blockspec/anchor.py +1250 -0
- blockspec/cli.py +133 -0
- blockspec/document.py +1212 -0
- blockspec/edit.py +234 -0
- blockspec-0.1.0.dist-info/METADATA +137 -0
- blockspec-0.1.0.dist-info/RECORD +16 -0
- blockspec-0.1.0.dist-info/WHEEL +4 -0
- blockspec-0.1.0.dist-info/entry_points.txt +2 -0
- blockspec-0.1.0.dist-info/licenses/LICENSE +87 -0
- blockspec-0.1.0.dist-info/licenses/LICENSES/Apache-2.0.txt +73 -0
- blockspec-0.1.0.dist-info/licenses/LICENSES/CC-BY-4.0.txt +156 -0
- blockspec-0.1.0.dist-info/licenses/LICENSES/CC0-1.0.txt +121 -0
- blockspec-0.1.0.dist-info/licenses/LICENSES/MIT.txt +21 -0
- blockspec-0.1.0.dist-info/licenses/LICENSES/PSF-2.0.txt +277 -0
blockspec/cli.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0 OR MIT
|
|
2
|
+
"""`blockspec check`: read documents and report each one the profile refuses.
|
|
3
|
+
|
|
4
|
+
Exit codes follow kindkit's, because "nothing was checked" must never look
|
|
5
|
+
like "nothing was wrong":
|
|
6
|
+
|
|
7
|
+
0 every document read
|
|
8
|
+
1 at least one document refused
|
|
9
|
+
2 no verdict: a path does not exist, there were no documents to read,
|
|
10
|
+
or a document could not be read and none was refused
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import io
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
from collections.abc import Iterable
|
|
20
|
+
|
|
21
|
+
from blockspec.document import Refusal, parse
|
|
22
|
+
|
|
23
|
+
#: Never documents. A `*.notes` directory is read: a record is a document
|
|
24
|
+
#: (SPEC.md §11.1).
|
|
25
|
+
SKIP_DIRS = {".git", ".hg", ".svn", ".venv", "node_modules", "__pycache__"}
|
|
26
|
+
SUFFIX = ".md"
|
|
27
|
+
EXIT_OK, EXIT_REFUSED, EXIT_NO_VERDICT = 0, 1, 2
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def collect(targets: Iterable[str]) -> list[str]:
|
|
31
|
+
"""Every document under `targets`. A file named directly is read whatever
|
|
32
|
+
its name; a directory contributes its `.md` files."""
|
|
33
|
+
files: list[str] = []
|
|
34
|
+
for target in targets:
|
|
35
|
+
if not os.path.isdir(target):
|
|
36
|
+
files.append(target)
|
|
37
|
+
continue
|
|
38
|
+
for directory, subdirs, names in os.walk(target):
|
|
39
|
+
subdirs[:] = sorted(d for d in subdirs if d not in SKIP_DIRS)
|
|
40
|
+
files += [os.path.join(directory, n) for n in names if n.lower().endswith(SUFFIX)]
|
|
41
|
+
return sorted({os.path.normpath(f) for f in files})
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _is_record(path: str) -> bool:
|
|
45
|
+
"""Whether `path` is in `<doc>.notes/`, beside its document (SPEC.md 11.1)."""
|
|
46
|
+
parent = os.path.dirname(os.path.abspath(path))
|
|
47
|
+
return parent.endswith(".notes") and os.path.isfile(parent[: -len(".notes")])
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Unreadable(Exception):
|
|
51
|
+
"""The file holds no answer either way: nothing was checked."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def check_file(path: str) -> tuple[str | None, str] | None:
|
|
55
|
+
"""None when the document reads; otherwise its refusal code, which is None
|
|
56
|
+
for a file no refusal code covers, and a message."""
|
|
57
|
+
if not os.path.isfile(path):
|
|
58
|
+
raise Unreadable("is not a regular file that exists")
|
|
59
|
+
try:
|
|
60
|
+
with open(path, "rb") as handle:
|
|
61
|
+
data = handle.read()
|
|
62
|
+
except OSError as exc:
|
|
63
|
+
raise Unreadable(f"cannot be read: {exc.strerror or exc}") from exc
|
|
64
|
+
try:
|
|
65
|
+
text = data.decode("utf-8")
|
|
66
|
+
except UnicodeDecodeError:
|
|
67
|
+
return None, "is not UTF-8"
|
|
68
|
+
try:
|
|
69
|
+
parse(text, record=_is_record(path))
|
|
70
|
+
except Refusal as refusal:
|
|
71
|
+
return refusal.code, refusal.message
|
|
72
|
+
except RecursionError as exc:
|
|
73
|
+
raise Unreadable("is nested too deeply to read") from exc
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _data(text: str) -> str:
|
|
78
|
+
"""A workflow command's message, escaped as GitHub requires."""
|
|
79
|
+
return text.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _prop(text: str) -> str:
|
|
83
|
+
"""A workflow command's property value, such as `file=`."""
|
|
84
|
+
return _data(text).replace(":", "%3A").replace(",", "%2C")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _report(path: str, code: str | None, message: str, fmt: str) -> None:
|
|
88
|
+
suffix = f" [{code}]" if code else ""
|
|
89
|
+
if fmt == "github":
|
|
90
|
+
print(f"::error file={_prop(path)},title={_prop('blockspec' + suffix)}::{_data(message)}")
|
|
91
|
+
else:
|
|
92
|
+
print(f"{path}: {message}{suffix}")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def check(paths: list[str], fmt: str = "plain") -> int:
|
|
96
|
+
missing = [p for p in paths if not os.path.exists(p)]
|
|
97
|
+
if missing:
|
|
98
|
+
for path in missing:
|
|
99
|
+
print(f"{path}: no such file or directory", file=sys.stderr)
|
|
100
|
+
return EXIT_NO_VERDICT
|
|
101
|
+
files = collect(paths)
|
|
102
|
+
if not files:
|
|
103
|
+
print(f"no documents under {' '.join(paths)}", file=sys.stderr)
|
|
104
|
+
return EXIT_NO_VERDICT
|
|
105
|
+
refused = unread = 0
|
|
106
|
+
for path in files:
|
|
107
|
+
try:
|
|
108
|
+
problem = check_file(path)
|
|
109
|
+
except Unreadable as exc:
|
|
110
|
+
unread += 1
|
|
111
|
+
print(f"{path}: {exc}", file=sys.stderr)
|
|
112
|
+
continue
|
|
113
|
+
if problem is not None:
|
|
114
|
+
refused += 1
|
|
115
|
+
_report(path, problem[0], problem[1], fmt)
|
|
116
|
+
print(f"{len(files) - unread} file(s) checked, {refused} refused, {unread} unreadable")
|
|
117
|
+
if refused:
|
|
118
|
+
return EXIT_REFUSED
|
|
119
|
+
return EXIT_NO_VERDICT if unread else EXIT_OK
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def main(argv: list[str] | None = None) -> int:
|
|
123
|
+
# A file's name can hold bytes the terminal's encoding cannot print.
|
|
124
|
+
for stream in (sys.stdout, sys.stderr):
|
|
125
|
+
if isinstance(stream, io.TextIOWrapper):
|
|
126
|
+
stream.reconfigure(errors="backslashreplace")
|
|
127
|
+
parser = argparse.ArgumentParser(prog="blockspec")
|
|
128
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
129
|
+
run = commands.add_parser("check", help="report every document the profile refuses")
|
|
130
|
+
run.add_argument("paths", nargs="+", help="files or directories")
|
|
131
|
+
run.add_argument("--format", choices=["plain", "github"], default="plain")
|
|
132
|
+
args = parser.parse_args(argv)
|
|
133
|
+
return check(args.paths, args.format)
|