-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcheck_examples.py
More file actions
393 lines (332 loc) · 14 KB
/
check_examples.py
File metadata and controls
393 lines (332 loc) · 14 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
#!/usr/bin/env python3
"""Verify that every example in the lessish documentation still works.
Run it from anywhere:
python docs/check_examples.py # check docs/ + README.md
python docs/check_examples.py --verbose # print every block as it runs
What it does
------------
It walks the Markdown files under ``docs/`` (plus the package ``README.md``)
and executes the fenced code blocks, so the docs can never drift from the
shipped behaviour without this script going red.
Two kinds of blocks are checked:
``python`` blocks
Executed as Python in a fresh namespace. ``LessishSecurityWarning`` (and
every other warning) is silenced for the run, so example code stays clean.
Assertions can be written two ways:
* a normal ``assert`` statement, or
* an *arrow comment* on the line after an assignment or a bare
expression::
css = ls.compile('@c: red; .a { color: @c; }')
# => '.a {\\n color: red;\\n}\\n'
The ``# =>`` (``# ->`` and the unicode ``# →`` also work) line asserts
that the value on the preceding line equals the Python literal to its
right.
``console`` blocks
Shell sessions. Lines starting with ``$ `` are commands; the lines that
follow (until the next ``$`` or the end of the block) are the expected
stdout. Commands run in a throwaway directory seeded with the fixtures in
``FIXTURES`` below, with ``PYTHONWARNINGS=ignore`` so the security banner
stays out of the captured output. A trailing ``# exit: N`` on a command
line asserts a non-zero exit code.
Opting out
----------
A block that is illustrative only (a signature sketch, a partial snippet)
is skipped if it contains the marker ``# docs: skip`` on a line of its own.
"""
from __future__ import annotations
import argparse
import os
import re
import shlex
import subprocess
import sys
import tempfile
import textwrap
import traceback
import warnings
from dataclasses import dataclass, field
from pathlib import Path
# --------------------------------------------------------------------------
# Fixtures for ``console`` blocks. Every CLI example runs in a temp dir that
# starts out holding exactly these files. Keep the set small and stable —
# the expected output baked into the docs is derived from this content.
# --------------------------------------------------------------------------
FIXTURES: dict[str, str] = {
# Compile + lint demo. Chosen so it both compiles to something tidy and
# trips a handful of lint rules (`#FFFFFF` → hex-short/hex-case, `0px` →
# zero-unit).
'styles.less': textwrap.dedent(
"""\
@brand: #4a90d9;
.button {
color: @brand;
border: 1px solid #FFFFFF;
margin: 0px;
&:hover { color: darken(@brand, 10%); }
}
"""
),
# A deliberately un-formatted file for the `format` examples.
'messy.less': '.a{color:red}\n',
# A single-line block carrying an inline lint directive — used to show
# that `lessish format` keeps the directive on the line it governs.
'directive.less': '.a { color: #FFFFFF; /* lessish-disable-line hex-short */ }\n',
}
SKIP_MARKER = '# docs: skip'
ARROW_RE = re.compile(r'^(?P<indent>\s*)#\s*(?:=>|->|→)\s*(?P<expected>.+?)\s*$')
# A top-level assignment: ``name = value`` with spaces around ``=``. The
# spaces matter — they keep keyword arguments (``fix_options=FixOptions(…)``)
# from being mistaken for assignments when walking back from a call's closer.
ASSIGN_RE = re.compile(r'^(?P<indent>\s*)(?P<lhs>[A-Za-z_][A-Za-z0-9_.]*)\s+=\s+\S.*$')
@dataclass
class Block:
lang: str
code: str
file: Path
line: int # 1-based line of the opening fence
@dataclass
class Result:
passed: int = 0
failed: int = 0
skipped: int = 0
failures: list[str] = field(default_factory=list)
# --------------------------------------------------------------------------
# Markdown extraction
# --------------------------------------------------------------------------
FENCE_RE = re.compile(r'^(?P<fence>```+)(?P<info>[^\n`]*)$')
def extract_blocks(path: Path) -> list[Block]:
blocks: list[Block] = []
lines = path.read_text(encoding='utf-8').splitlines()
i = 0
while i < len(lines):
m = FENCE_RE.match(lines[i].rstrip())
if not m:
i += 1
continue
fence = m.group('fence')
lang = m.group('info').strip().split()[0] if m.group('info').strip() else ''
start = i + 1
j = start
while j < len(lines) and lines[j].rstrip() != fence:
j += 1
code = '\n'.join(lines[start:j])
blocks.append(Block(lang=lang, code=code, file=path, line=i + 1))
i = j + 1
return blocks
# --------------------------------------------------------------------------
# Python block runner
# --------------------------------------------------------------------------
def _rewrite_arrows(code: str) -> str:
"""Turn ``# => <literal>`` arrow comments into real assertions.
The arrow refers to the value produced by the previous code line: either
the variable it assigned, or the bare expression it evaluated.
"""
src_lines = code.splitlines()
out: list[str] = []
for line in src_lines:
m = ARROW_RE.match(line)
if not m:
out.append(line)
continue
indent = m.group('indent')
expected = m.group('expected')
# Find the previous emitted code line (skip blanks / comments).
prev_idx = None
for k in range(len(out) - 1, -1, -1):
s = out[k].strip()
if s and not s.startswith('#'):
prev_idx = k
break
if prev_idx is None:
continue
prev = out[prev_idx].strip()
am = ASSIGN_RE.match(out[prev_idx])
if am:
target = am.group('lhs')
elif prev[:1] in ')]}':
# Closer of a multi-line assignment, e.g.
# fixed = linter.fix(
# source,
# )
# # => '…'
# Walk back to the nearest assignment line and use its LHS.
target = prev
for k in range(prev_idx - 1, -1, -1):
back = ASSIGN_RE.match(out[k])
if back:
target = back.group('lhs')
break
else:
target = prev # bare expression on the previous line
out.append(
f'{indent}assert ({target}) == ({expected}), '
f'"example mismatch: {{!r}} != {{!r}}".format(({target}), ({expected}))'
)
return '\n'.join(out)
def run_python_block(block: Block, result: Result, verbose: bool, namespace: dict[str, object]) -> None:
if SKIP_MARKER in block.code:
result.skipped += 1
return
code = _rewrite_arrows(block.code)
if verbose:
print(f' python {block.file}:{block.line}')
with warnings.catch_warnings():
warnings.simplefilter('ignore')
try:
compiled = compile(code, f'{block.file}:{block.line}', 'exec')
exec(compiled, namespace) # noqa: S102 — running our own docs
except Exception: # noqa: BLE001
result.failed += 1
tb = traceback.format_exc()
result.failures.append(f'PYTHON {block.file}:{block.line}\n{textwrap.indent(tb, " ")}')
return
result.passed += 1
# --------------------------------------------------------------------------
# Console block runner
# --------------------------------------------------------------------------
PROMPT = '$ '
EXIT_RE = re.compile(r'#\s*exit:\s*(\d+)\s*$')
# `src/` next to `docs/` — put it on the subprocess PYTHONPATH so the
# `lessish` package imports without a prior `pip install` (the doc
# examples must run straight from a checkout / in CI).
_SRC_DIR = Path(__file__).resolve().parent.parent / 'src'
# Rewrite the documented program names to the *current* interpreter so
# the examples are hermetic: they don't depend on a `lessish` console
# script being on PATH or on `python` resolving to anything in
# particular. Only a token in command position (start, or right after a
# shell operator) is touched, so a filename argument named `python`
# stays intact.
_CMD_HEAD_RE = re.compile(r'(^|[|&;]\s*)(lessish|python3|python)\b')
def _rewrite_cmd(cmd: str) -> str:
exe = shlex.quote(sys.executable)
def repl(m: re.Match[str]) -> str:
prog = m.group(2)
replacement = f'{exe} -m lessish' if prog == 'lessish' else exe
return f'{m.group(1)}{replacement}'
return _CMD_HEAD_RE.sub(repl, cmd)
def _parse_console(code: str) -> list[tuple[str, int, str]]:
"""Split a console block into (command, expected_exit, expected_stdout)."""
steps: list[tuple[str, int, str]] = []
lines = code.splitlines()
i = 0
while i < len(lines):
line = lines[i]
if not line.startswith(PROMPT):
i += 1
continue
cmd = line[len(PROMPT) :]
exit_code = 0
em = EXIT_RE.search(cmd)
if em:
exit_code = int(em.group(1))
cmd = cmd[: em.start()].rstrip()
expected: list[str] = []
i += 1
while i < len(lines) and not lines[i].startswith(PROMPT):
expected.append(lines[i])
i += 1
steps.append((cmd, exit_code, '\n'.join(expected)))
return steps
# Collapse a `lessish <version>` banner line to a stable token. The
# concrete version is whatever `importlib.metadata` reports: the real
# release version when run against an installed wheel (CI / conformance),
# but `0.0.0+dev` when run straight from a source checkout (the unit
# test). Pinning an exact number in the docs would also rot on every
# version bump — matching the *shape* keeps the `version` example
# meaningful (it ran, exited 0, printed a version) without that fragility.
_VERSION_LINE_RE = re.compile(r'^lessish \d+\.\d+\.\d+\S*$', re.MULTILINE)
def _normalize(text: str) -> str:
text = _VERSION_LINE_RE.sub('lessish <version>', text)
lines = [ln.rstrip() for ln in text.splitlines()]
while lines and not lines[-1]:
lines.pop()
return '\n'.join(lines)
def run_console_block(block: Block, result: Result, verbose: bool) -> None:
if SKIP_MARKER in block.code:
result.skipped += 1
return
steps = _parse_console(block.code)
if not steps:
result.skipped += 1
return
env = dict(os.environ)
env['PYTHONWARNINGS'] = 'ignore'
# Absolute PYTHONPATH so the rewritten `python -m lessish` resolves
# the package from the checkout (cwd below is a temp dir, so a
# relative entry would not).
env['PYTHONPATH'] = os.pathsep.join(p for p in (str(_SRC_DIR), env.get('PYTHONPATH', '')) if p)
with tempfile.TemporaryDirectory(prefix='lessish-docs-') as tmp:
workdir = Path(tmp)
for name, content in FIXTURES.items():
(workdir / name).write_text(content, encoding='utf-8')
for cmd, want_exit, want_out in steps:
if verbose:
print(f' console {block.file}:{block.line} $ {cmd}')
proc = subprocess.run(
_rewrite_cmd(cmd),
shell=True, # noqa: S602 — running our own documented commands
cwd=workdir,
env=env,
capture_output=True,
text=True,
)
if proc.returncode != want_exit:
result.failed += 1
result.failures.append(
f'CONSOLE {block.file}:{block.line}\n'
f' $ {cmd}\n'
f' exit {proc.returncode}, expected {want_exit}\n'
f' stderr: {proc.stderr.strip()[:400]}'
)
return
if want_out.strip():
got = _normalize(proc.stdout)
exp = _normalize(want_out)
if got != exp:
result.failed += 1
result.failures.append(
f'CONSOLE {block.file}:{block.line}\n'
f' $ {cmd}\n'
f' --- expected ---\n{textwrap.indent(exp, " ")}\n'
f' --- got ---\n{textwrap.indent(got, " ")}'
)
return
result.passed += 1
# --------------------------------------------------------------------------
# Driver
# --------------------------------------------------------------------------
def iter_doc_files(docs_dir: Path, readme: Path) -> list[Path]:
files = sorted(p for p in docs_dir.rglob('*.md'))
if readme.exists():
files.append(readme)
return files
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description='Verify lessish documentation examples.')
parser.add_argument('-v', '--verbose', action='store_true', help='print every block as it runs')
args = parser.parse_args(argv)
docs_dir = Path(__file__).resolve().parent
package_root = docs_dir.parent
readme = package_root / 'README.md'
result = Result()
for path in iter_doc_files(docs_dir, readme):
# Python blocks in one file share a namespace, top to bottom, so a
# later block can build on names a earlier one defined (the way a
# reader steps through the page). Each file starts fresh.
namespace: dict[str, object] = {'__name__': '__doc_example__'}
for block in extract_blocks(path):
if block.lang == 'python':
run_python_block(block, result, args.verbose, namespace)
elif block.lang == 'console':
run_console_block(block, result, args.verbose)
# Other languages (toml, less, text, …) are documentation-only.
print()
print(f'examples: {result.passed} passed, {result.failed} failed, {result.skipped} skipped')
if result.failures:
print('\n' + '=' * 72)
for f in result.failures:
print(f)
print('-' * 72)
return 1
return 0
if __name__ == '__main__':
sys.exit(main())