Marco Ricci commited on 2025-02-09 20:47:17
Zeige 2 geänderte Dateien mit 370 Einfügungen und 0 Löschungen.
Add a quality control script `qc_auto.py` that calls the linter, the formatter, the type checker, the test suite and the documentation builder, depending on what branch we are on. It is intended to be usable as a pre-commit hook. Add another quality control script `man_diagnostics.py` that checks that the diagnostics documented in the manpages are complete, and match the enum values within `derivepassphrase`. It relies on annotations (comments) in the manpages to map description texts to the respective enum values (the mapping is not one-to-one). It also does a (rudimentary) check that every warning and error message is mentioned somewhere in the source tree (besides the messages module that defines those messages).
... | ... |
@@ -0,0 +1,283 @@ |
1 |
+#!/usr/bin/python3 |
|
2 |
+# SPDX-FileCopyrightText: 2025 Marco Ricci <software@the13thletter.info> |
|
3 |
+# |
|
4 |
+# SPDX-License-Identifier: Zlib |
|
5 |
+ |
|
6 |
+"""Check for diagnostic messages not emitted in the manpages.""" |
|
7 |
+ |
|
8 |
+from __future__ import annotations |
|
9 |
+ |
|
10 |
+import pathlib |
|
11 |
+import re |
|
12 |
+import sys |
|
13 |
+from typing import TYPE_CHECKING, Literal, NewType, cast |
|
14 |
+ |
|
15 |
+sys.path.append(str(pathlib.Path(sys.argv[0]).resolve().parent.parent / 'src')) |
|
16 |
+from derivepassphrase._internals import cli_messages # noqa: PLC2701 |
|
17 |
+ |
|
18 |
+if TYPE_CHECKING: |
|
19 |
+ from collections.abc import Iterator |
|
20 |
+ |
|
21 |
+ EnumName = NewType('EnumName', str) |
|
22 |
+ DiagnosticText = NewType('DiagnosticText', str) |
|
23 |
+ |
|
24 |
+known_errors = cli_messages.ErrMsgTemplate.__members__ |
|
25 |
+known_warnings = cli_messages.WarnMsgTemplate.__members__ |
|
26 |
+ |
|
27 |
+ |
|
28 |
+def _replace_known_metavars(string: str) -> str: |
|
29 |
+ return ( |
|
30 |
+ string.replace( |
|
31 |
+ '{service_metavar!s}', |
|
32 |
+ cli_messages.Label.VAULT_METAVAR_SERVICE.value.singular, |
|
33 |
+ ) |
|
34 |
+ .replace('{PROG_NAME!s}', cli_messages.PROG_NAME) |
|
35 |
+ .replace('{settings_type!s}', 'global/service-specific settings') |
|
36 |
+ ) |
|
37 |
+ |
|
38 |
+ |
|
39 |
+# Use a double negative in the name ("does not mismatch text") because |
|
40 |
+# this is an error condition check, and if the enum name doesn't exist |
|
41 |
+# (because the manpage is outdated), then there is no mismatch. This is |
|
42 |
+# clearer (to me at least) than erroneously claiming that a missing text |
|
43 |
+# matches the desired pattern. |
|
44 |
+def _mismatches_text( |
|
45 |
+ pattern: re.Pattern[str], |
|
46 |
+ enum_name: EnumName, |
|
47 |
+ name_type: Literal['warning', 'error'], |
|
48 |
+) -> bool: |
|
49 |
+ while '.' in enum_name: |
|
50 |
+ enum_name = cast('EnumName', enum_name.partition('.')[2]) |
|
51 |
+ try: |
|
52 |
+ enum_value = ( |
|
53 |
+ known_errors[enum_name].value |
|
54 |
+ if name_type == 'error' |
|
55 |
+ else known_warnings[enum_name].value |
|
56 |
+ ) |
|
57 |
+ except KeyError: |
|
58 |
+ # No text, so no mismatch. |
|
59 |
+ return False |
|
60 |
+ texts = {enum_value.singular, enum_value.plural} - {''} |
|
61 |
+ return not all(pattern.match(_replace_known_metavars(t)) for t in texts) |
|
62 |
+ |
|
63 |
+ |
|
64 |
+def _entries_from_text( |
|
65 |
+ text: DiagnosticText, |
|
66 |
+ enum_names: set[EnumName], |
|
67 |
+) -> Iterator[ |
|
68 |
+ tuple[ |
|
69 |
+ Literal['warning', 'error'], |
|
70 |
+ tuple[DiagnosticText, EnumName], |
|
71 |
+ ] |
|
72 |
+]: |
|
73 |
+ assert text not in manpage_documented_warnings |
|
74 |
+ assert text not in manpage_documented_errors |
|
75 |
+ pattern_parts = [ |
|
76 |
+ '.*' if part == '%s' else re.escape(part) |
|
77 |
+ for part in re.split(r'(%s)', text) |
|
78 |
+ ] |
|
79 |
+ pattern = re.compile(''.join(pattern_parts)) |
|
80 |
+ for name in enum_names: |
|
81 |
+ _class_name, dot, enum_entry = name.partition('.') |
|
82 |
+ assert dot == '.', f'Invalid enum name {name!r}' |
|
83 |
+ assert '.' not in enum_entry, f'Unsupported enum name {name!r}' |
|
84 |
+ if name.startswith('WarnMsgTemplate.'): |
|
85 |
+ assert not _mismatches_text( |
|
86 |
+ pattern, enum_name=name, name_type='warning' |
|
87 |
+ ), ( |
|
88 |
+ f"Warning text for {name} doesn't match the manpage: " |
|
89 |
+ f'{text!r} -> {pattern.pattern!r}' |
|
90 |
+ ) |
|
91 |
+ yield ('warning', (text, cast('EnumName', enum_entry))) |
|
92 |
+ if name.startswith('ErrMsgTemplate.'): |
|
93 |
+ assert not _mismatches_text( |
|
94 |
+ pattern, enum_name=name, name_type='error' |
|
95 |
+ ), ( |
|
96 |
+ f"Error text for {name} doesn't match the manpage: " |
|
97 |
+ f'{text!r} -> {pattern.pattern!r}' |
|
98 |
+ ) |
|
99 |
+ yield ('error', (text, cast('EnumName', enum_entry))) |
|
100 |
+ |
|
101 |
+ |
|
102 |
+def _check_manpage( |
|
103 |
+ path: pathlib.Path, |
|
104 |
+) -> Iterator[ |
|
105 |
+ tuple[ |
|
106 |
+ Literal['warning', 'error'], |
|
107 |
+ tuple[DiagnosticText, EnumName], |
|
108 |
+ ] |
|
109 |
+]: |
|
110 |
+ enum_names: set[EnumName] = set() |
|
111 |
+ |
|
112 |
+ for line in path.read_text(encoding='UTF-8').splitlines(keepends=False): |
|
113 |
+ if enum_names and line.startswith('.It '): |
|
114 |
+ # Some *roff escape sequences need to be undone. This is not an |
|
115 |
+ # exhaustive list; new entries will be added based on the actual |
|
116 |
+ # manpages as the need arises. |
|
117 |
+ text = cast( |
|
118 |
+ 'DiagnosticText', |
|
119 |
+ line.removeprefix('.It ').replace('"', '').replace(r'\-', '-'), |
|
120 |
+ ) |
|
121 |
+ yield from _entries_from_text(text=text, enum_names=enum_names) |
|
122 |
+ enum_names.clear() |
|
123 |
+ elif line.startswith(r'.\" Message-ID (mark only):'): |
|
124 |
+ yield from _entries_from_mark_only( |
|
125 |
+ cast('EnumName', line.split(None, 4)[4]) |
|
126 |
+ ) |
|
127 |
+ elif line.startswith(r'.\" Message-ID:'): |
|
128 |
+ enum_names.add(cast('EnumName', line.split(None, 2)[2])) |
|
129 |
+ |
|
130 |
+ |
|
131 |
+def _entries_from_mark_only( |
|
132 |
+ name: EnumName, |
|
133 |
+) -> Iterator[ |
|
134 |
+ tuple[ |
|
135 |
+ Literal['warning', 'error'], |
|
136 |
+ tuple[DiagnosticText, EnumName], |
|
137 |
+ ] |
|
138 |
+]: |
|
139 |
+ text = cast('DiagnosticText', '<mark only>') |
|
140 |
+ _class_name, dot, enum_entry = name.partition('.') |
|
141 |
+ assert dot == '.', f'Invalid enum name {name!r}' |
|
142 |
+ assert '.' not in enum_entry, f'Unsupported enum name {name!r}' |
|
143 |
+ if name.startswith('WarnMsgTemplate.'): |
|
144 |
+ yield ('warning', (text, cast('EnumName', enum_entry))) |
|
145 |
+ if name.startswith('ErrMsgTemplate.'): |
|
146 |
+ yield ('error', (text, cast('EnumName', enum_entry))) |
|
147 |
+ |
|
148 |
+ |
|
149 |
+def _check_manpagedoc( |
|
150 |
+ path: pathlib.Path, |
|
151 |
+) -> Iterator[ |
|
152 |
+ tuple[ |
|
153 |
+ Literal['warning', 'error'], |
|
154 |
+ tuple[DiagnosticText, EnumName], |
|
155 |
+ ] |
|
156 |
+]: |
|
157 |
+ enum_names: set[EnumName] = set() |
|
158 |
+ |
|
159 |
+ for line in path.read_text(encoding='UTF-8').splitlines(keepends=False): |
|
160 |
+ if enum_names and line.startswith(('??? failure ', '??? warning ')): |
|
161 |
+ text = cast('DiagnosticText', line.split(None, 2)[2]) |
|
162 |
+ for ch in ['"', '`']: |
|
163 |
+ assert text.startswith(ch) |
|
164 |
+ assert text.endswith(ch) |
|
165 |
+ text = cast('DiagnosticText', text[1:-1]) |
|
166 |
+ yield from _entries_from_text(text=text, enum_names=enum_names) |
|
167 |
+ enum_names.clear() |
|
168 |
+ elif line.startswith('<!-- Message-ID (mark only):') and line.endswith( |
|
169 |
+ '-->' |
|
170 |
+ ): |
|
171 |
+ name = cast( |
|
172 |
+ 'EnumName', |
|
173 |
+ line.removeprefix('<!-- Message-ID (mark only):') |
|
174 |
+ .removesuffix('-->') |
|
175 |
+ .strip(), |
|
176 |
+ ) |
|
177 |
+ yield from _entries_from_mark_only(name) |
|
178 |
+ elif line.startswith('<!-- Message-ID:') and line.endswith('-->'): |
|
179 |
+ name = cast( |
|
180 |
+ 'EnumName', |
|
181 |
+ line.removeprefix('<!-- Message-ID:') |
|
182 |
+ .removesuffix('-->') |
|
183 |
+ .strip(), |
|
184 |
+ ) |
|
185 |
+ enum_names.add(name) |
|
186 |
+ |
|
187 |
+ |
|
188 |
+base = pathlib.Path(sys.argv[0]).resolve().parent.parent |
|
189 |
+manpage_documented_errors: dict[EnumName, DiagnosticText] = {} |
|
190 |
+manpage_documented_warnings: dict[EnumName, DiagnosticText] = {} |
|
191 |
+manpagedoc_documented_errors: dict[EnumName, DiagnosticText] = {} |
|
192 |
+manpagedoc_documented_warnings: dict[EnumName, DiagnosticText] = {} |
|
193 |
+for set_name, globs, errors, warnings in [ |
|
194 |
+ ( |
|
195 |
+ 'manpages', |
|
196 |
+ sorted(pathlib.Path(base, 'man').glob('derivepassphrase*.1')), |
|
197 |
+ manpage_documented_errors, |
|
198 |
+ manpage_documented_warnings, |
|
199 |
+ ), |
|
200 |
+ ( |
|
201 |
+ 'manpage-ish docs', |
|
202 |
+ sorted( |
|
203 |
+ pathlib.Path(base, 'docs', 'reference').glob( |
|
204 |
+ 'derivepassphrase*.1.md' |
|
205 |
+ ) |
|
206 |
+ ), |
|
207 |
+ manpagedoc_documented_errors, |
|
208 |
+ manpagedoc_documented_warnings, |
|
209 |
+ ), |
|
210 |
+]: |
|
211 |
+ for path in globs: |
|
212 |
+ print(f'Checking manpage {path}', file=sys.stderr) |
|
213 |
+ checker = ( |
|
214 |
+ _check_manpage if set_name == 'manpages' else _check_manpagedoc |
|
215 |
+ ) |
|
216 |
+ for diagnostic_type, (text, name) in checker(path): |
|
217 |
+ if diagnostic_type == 'warning': |
|
218 |
+ warnings[name] = text |
|
219 |
+ print( |
|
220 |
+ f'Found warning message {name!r} with {text!r} in manpage.', # noqa: E501 |
|
221 |
+ file=sys.stderr, |
|
222 |
+ ) |
|
223 |
+ else: |
|
224 |
+ errors[name] = text |
|
225 |
+ print( |
|
226 |
+ f'Found error message {name!r} with {text!r} in manpage.', |
|
227 |
+ file=sys.stderr, |
|
228 |
+ ) |
|
229 |
+ assert set(errors) >= set(known_errors), ( |
|
230 |
+ f"Some error messages aren't documented in the {set_name}: " |
|
231 |
+ + repr(set(known_errors) - set(errors)) |
|
232 |
+ ) |
|
233 |
+ assert set(warnings) >= set(known_warnings), ( |
|
234 |
+ f"Some warning messages aren't documented in the {set_name}: " |
|
235 |
+ + repr(set(known_warnings) - set(warnings)) |
|
236 |
+ ) |
|
237 |
+ assert set(errors) <= set(known_errors), ( |
|
238 |
+ f'Some unknown error messages are documented in the {set_name}: ' |
|
239 |
+ + repr(set(errors) - set(known_errors)) # type: ignore[arg-type] |
|
240 |
+ ) |
|
241 |
+ assert set(warnings) <= set(known_warnings), ( |
|
242 |
+ f'Some unknown warning messages are documented in the {set_name}: ' |
|
243 |
+ + repr(set(warnings) - set(known_warnings)) # type: ignore[arg-type] |
|
244 |
+ ) |
|
245 |
+ |
|
246 |
+py_file_errors: set[EnumName] = set() |
|
247 |
+py_file_warnings: set[EnumName] = set() |
|
248 |
+match_errors_warnings = re.compile( |
|
249 |
+ r'\b(?:cli_messages|msg|_msg)\.(Err|Warn)MsgTemplate\.([A-Z0-9_]+)' |
|
250 |
+) |
|
251 |
+for path in pathlib.Path(base, 'src', 'derivepassphrase').glob('**/*.py'): |
|
252 |
+ if path != pathlib.Path( |
|
253 |
+ base, 'src', 'derivepassphrase', '_internals', 'cli_messages.py' |
|
254 |
+ ): |
|
255 |
+ filecontents = path.read_text(encoding='UTF-8') |
|
256 |
+ for match in match_errors_warnings.finditer(filecontents): |
|
257 |
+ message_type, symbol = match.group(1, 2) |
|
258 |
+ if message_type == 'Err': |
|
259 |
+ py_file_errors.add(cast('EnumName', symbol)) |
|
260 |
+ print( |
|
261 |
+ f'Found mention of error message {symbol} ' |
|
262 |
+ f'in source file {path!r}.', |
|
263 |
+ file=sys.stderr, |
|
264 |
+ ) |
|
265 |
+ elif message_type == 'Warn': |
|
266 |
+ py_file_warnings.add(cast('EnumName', symbol)) |
|
267 |
+ print( |
|
268 |
+ f'Found mention of warning message {symbol} ' |
|
269 |
+ f'in source file {path!r}.', |
|
270 |
+ file=sys.stderr, |
|
271 |
+ ) |
|
272 |
+if py_file_errors != set(known_errors): |
|
273 |
+ print( |
|
274 |
+ "Some error messages aren't in use: " |
|
275 |
+ + repr(set(known_errors) - py_file_errors), |
|
276 |
+ file=sys.stderr, |
|
277 |
+ ) |
|
278 |
+if py_file_warnings != set(known_warnings): |
|
279 |
+ print( |
|
280 |
+ "Some warning messages aren't in use: " |
|
281 |
+ + repr(set(known_warnings) - py_file_warnings), |
|
282 |
+ file=sys.stderr, |
|
283 |
+ ) |
... | ... |
@@ -0,0 +1,87 @@ |
1 |
+#!/usr/bin/python3 |
|
2 |
+# SPDX-FileCopyrightText: 2025 Marco Ricci <software@the13thletter.info> |
|
3 |
+# |
|
4 |
+# SPDX-License-Identifier: Zlib |
|
5 |
+ |
|
6 |
+# ruff: noqa: S404,S603,S607 |
|
7 |
+ |
|
8 |
+"""Run various quality control checks automatically. |
|
9 |
+ |
|
10 |
+Distinguish between the master branch and other branches: run the full |
|
11 |
+test suite and build the documentation only on the master branch, |
|
12 |
+otherwise use only a reduced set of test environments and don't build |
|
13 |
+the documentation at all. In both cases, run the linter, the formatter, |
|
14 |
+and the type checker. |
|
15 |
+ |
|
16 |
+If we are currently in a Stacked Git patch queue, do not run any tests, |
|
17 |
+do not run the type checker and do not build the documentation. These |
|
18 |
+all slow down patch refreshing to a grinding halt, and will be checked |
|
19 |
+afterwards anyway when merging the patch queue back into the master |
|
20 |
+branch. Stick to formatting and linting only. |
|
21 |
+ |
|
22 |
+""" |
|
23 |
+ |
|
24 |
+import os |
|
25 |
+import subprocess |
|
26 |
+import sys |
|
27 |
+ |
|
28 |
+envs = ['3.9', '3.11', '3.13', 'pypy3.10'] |
|
29 |
+opts = ['-py', ','.join(envs)] |
|
30 |
+ |
|
31 |
+current_branch = subprocess.run( |
|
32 |
+ ['git', 'branch', '--show-current'], |
|
33 |
+ capture_output=True, |
|
34 |
+ text=True, |
|
35 |
+ check=False, |
|
36 |
+).stdout.strip() |
|
37 |
+# We use rev-parse to check for Stacked Git's metadata tracking branch, |
|
38 |
+# instead of checking `stg top` or similar, because we also want the |
|
39 |
+# first `stg new` or `stg import` to correctly detect that we are |
|
40 |
+# working on a patch queue. |
|
41 |
+is_stgit_patch = bool( |
|
42 |
+ subprocess.run( |
|
43 |
+ [ |
|
44 |
+ 'git', |
|
45 |
+ 'rev-parse', |
|
46 |
+ '--verify', |
|
47 |
+ '--end-of-options', |
|
48 |
+ f'refs/stacks/{current_branch}', |
|
49 |
+ ], |
|
50 |
+ capture_output=True, |
|
51 |
+ check=False, |
|
52 |
+ ).stdout |
|
53 |
+) |
|
54 |
+ |
|
55 |
+try: |
|
56 |
+ subprocess.run(['hatch', 'fmt', '-l'], check=True) |
|
57 |
+ subprocess.run(['hatch', 'fmt', '-f'], check=True) |
|
58 |
+ if current_branch == 'master': |
|
59 |
+ subprocess.run( |
|
60 |
+ ['hatch', 'env', 'run', '-e', 'types', '--', 'check'], check=True |
|
61 |
+ ) |
|
62 |
+ subprocess.run( |
|
63 |
+ ['hatch', 'test', '-acpqr', '--', '--maxfail', '1'], |
|
64 |
+ check=True, |
|
65 |
+ ) |
|
66 |
+ # fmt: off |
|
67 |
+ subprocess.run( |
|
68 |
+ [ |
|
69 |
+ 'hatch', 'env', 'run', '-e', 'docs', '--', |
|
70 |
+ 'build', '-f', 'mkdocs_devsetup.yaml', |
|
71 |
+ ], |
|
72 |
+ check=True, |
|
73 |
+ ) |
|
74 |
+ # fmt: on |
|
75 |
+ elif not is_stgit_patch: |
|
76 |
+ subprocess.run( |
|
77 |
+ ['hatch', 'env', 'run', '-e', 'types', '--', 'check'], check=True |
|
78 |
+ ) |
|
79 |
+ subprocess.run( |
|
80 |
+ ['hatch', 'test', '-cpqr', *opts, '--', '--maxfail', '1'], |
|
81 |
+ env={**os.environ} | {'HYPOTHESIS_PROFILE': 'dev'}, |
|
82 |
+ check=True, |
|
83 |
+ ) |
|
84 |
+except subprocess.CalledProcessError as exc: |
|
85 |
+ sys.exit(getattr(exc, 'returncode', 1)) |
|
86 |
+except KeyboardInterrupt: |
|
87 |
+ sys.exit(1) |
|
0 | 88 |