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/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)