Skip to content

Commit 96ef52c

Browse files
committed
Add filepath globbing; review docs.
1 parent 6bef425 commit 96ef52c

File tree

3 files changed

+132
-56
lines changed

3 files changed

+132
-56
lines changed

.github/workflows/ci-tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ jobs:
8383
- name: "Run doctests: Docs"
8484
if: matrix.session == 'doctests-docs'
8585
run: |
86-
tools/run_doctests.py -v $(find ./docs -iname '*.rst')
86+
tools/run_doctests.py -v "./docs/**/*.rst"
8787
8888
- name: "Run doctests: API"
8989
if: matrix.session == 'doctests-api'

tools/check_doctest.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515

1616
tstargs = ["-mvr", "iris._combine", "-o", "raise_on_error=True"]
1717

18+
tstargs = ["-r", "../docs/userdocs/**/*.rst", "-e", "started.rst"]
19+
1820
args = _parser.parse_args(tstargs)
1921
kwargs = parserargs_as_kwargs(args)
2022
# if not "options" in kwargs:

tools/run_doctests.py

Lines changed: 129 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import argparse
33
import doctest
44
import importlib
5+
import os
56
import traceback
67
from pathlib import Path
78
import pkgutil
@@ -13,6 +14,10 @@ def list_modules_recursive(
1314
module_importname: str, include_private: bool = True,
1415
exclude_matches: list[str] = []
1516
):
17+
"""Find all the submodules of a given module.
18+
19+
Also filter with private and exclude controls.
20+
"""
1621
module_names = [module_importname]
1722
# Identify module from its import path (no import -> fail back to caller)
1823
try:
@@ -40,7 +45,8 @@ def list_modules_recursive(
4045
if ispkg:
4146
module_names.extend(
4247
list_modules_recursive(
43-
submodule_name, include_private=include_private
48+
submodule_name, include_private=include_private,
49+
exclude_matches=exclude_matches,
4450
)
4551
)
4652

@@ -54,6 +60,38 @@ def list_modules_recursive(
5460
return result
5561

5662

63+
def list_filepaths_recursive(
64+
file_path: str,
65+
exclude_matches: list[str] = []
66+
) -> list[Path]:
67+
"""Expand globs to a list of filepaths.
68+
69+
Also filter with exclude controls.
70+
"""
71+
actual_paths: list[Path] = []
72+
segments = file_path.split("/")
73+
i_wilds = [
74+
index for index, segment in enumerate(segments)
75+
if any(char in segment for char in "*?[")
76+
]
77+
if len(i_wilds) == 0:
78+
actual_paths.append(Path(file_path))
79+
else:
80+
i_first_wild = i_wilds[0]
81+
base_path = Path("/".join(segments[:i_first_wild]))
82+
file_spec = "/".join(segments[i_first_wild:])
83+
# This is the magic bit! expand with globs, '**' enabling recursive
84+
actual_paths += list(base_path.glob(file_spec))
85+
86+
# Also apply exclude and private filters to results
87+
result = [
88+
path for path in actual_paths
89+
if not any(match in str(path) for match in exclude_matches)
90+
and not path.name.startswith("_")
91+
]
92+
return result
93+
94+
5795
def process_options(opt_str: str, paths_are_modules: bool = True) -> dict[str, str]:
5896
"""Convert the "-o/--options" arg into a **kwargs for the doctest function call."""
5997
# Remove all spaces (think they are never needed).
@@ -65,7 +103,7 @@ def process_options(opt_str: str, paths_are_modules: bool = True) -> dict[str, s
65103
try:
66104
name, val = setting_str.split("=")
67105

68-
# Detect + translate numberic and boolean values.
106+
# Detect + translate numeric and boolean values.
69107
bool_vals = {"true": True, "false": False}
70108
if val.isdigit():
71109
val = int(val)
@@ -98,12 +136,13 @@ def run_doctest_paths(
98136
recurse_modules: bool = False,
99137
include_private_modules: bool = False,
100138
exclude_matches: list[str] = [],
101-
option_kwargs: dict = {},
139+
doctest_kwargs: dict = {},
102140
verbose: bool = False,
103141
dry_run: bool = False,
104142
stop_on_failure: bool = False,
105143
):
106144
n_total_fails, n_total_tests, n_paths_tested = 0, 0, 0
145+
107146
if verbose:
108147
print(
109148
"RUNNING run_doctest("
@@ -112,15 +151,17 @@ def run_doctest_paths(
112151
f", recurse_modules={recurse_modules!r}"
113152
f", include_private_modules={include_private_modules!r}"
114153
f", exclude_matches={exclude_matches!r}"
115-
f", option_kwargs={option_kwargs!r}"
154+
f", doctest_kwargs={doctest_kwargs!r}"
116155
f", verbose={verbose!r}"
117156
f", dry_run={dry_run!r}"
118157
f", stop_on_failure={stop_on_failure!r}"
119158
")"
120159
)
160+
121161
if dry_run:
122162
verbose = True
123163

164+
# For now at least, simply discard ALL warnings.
124165
warnings.simplefilter("ignore")
125166

126167
if paths_are_modules:
@@ -133,53 +174,62 @@ def run_doctest_paths(
133174
exclude_matches=exclude_matches
134175
)
135176
paths = module_paths
136-
137-
else: # paths are filepaths
177+
else:
178+
# paths are filepaths
138179
doctest_function = doctest.testfile
180+
filepaths = []
181+
for path in paths:
182+
filepaths += list_filepaths_recursive(
183+
path,
184+
exclude_matches=exclude_matches
185+
)
186+
paths = filepaths
139187

140188
for path in paths:
141189
if verbose:
142190
print(f"\n-----\ndoctest.{doctest_function.__name__}: {path!r}")
143-
if not dry_run:
144-
op_fail = None
145-
if paths_are_modules:
146-
try:
147-
arg = importlib.import_module(path)
148-
except Exception as exc:
149-
op_fail = exc
150-
else:
151-
arg = path
152-
153-
if op_fail is None:
154-
try:
155-
n_fails, n_tests = doctest_function(arg, **option_kwargs)
156-
n_total_fails += n_fails
157-
n_total_tests += n_tests
158-
n_paths_tested += 1
159-
if n_fails:
160-
print(f"\nERRORS from doctests in path: {arg}\n")
161-
except Exception as exc:
162-
op_fail = exc
163-
164-
if op_fail is not None:
165-
n_total_fails += 1
166-
print(f"\n\nERROR occurred at {path!r}: {op_fail}\n")
167-
if isinstance(op_fail, doctest.UnexpectedException):
168-
# This is what happens with "-o raise_on_error=True", which is the
169-
# Python call equivalent of "-o FAIL_FAST" in the doctest CLI.
170-
print(f"Doctest caught exception: {op_fail}")
171-
traceback.print_exception(*op_fail.exc_info)
172-
173-
if n_total_fails > 0 and stop_on_failure:
174-
break
191+
if dry_run:
192+
continue
193+
194+
op_fail = None
195+
if paths_are_modules:
196+
try:
197+
arg = importlib.import_module(path)
198+
except Exception as exc:
199+
op_fail = exc
200+
else:
201+
arg = path
202+
203+
if op_fail is None:
204+
try:
205+
n_fails, n_tests = doctest_function(arg, **doctest_kwargs)
206+
n_total_fails += n_fails
207+
n_total_tests += n_tests
208+
n_paths_tested += 1
209+
if n_fails:
210+
print(f"\nERRORS in path: {arg}\n")
211+
except Exception as exc:
212+
op_fail = exc
213+
214+
if op_fail is not None:
215+
n_total_fails += 1
216+
print(f"\n\nERROR occurred at {path!r}: {op_fail}\n")
217+
if isinstance(op_fail, doctest.UnexpectedException):
218+
# E.G. this is what happens with "-o raise_on_error=True", which is
219+
# the Python call equivalent of "-o FAIL_FAST" in the doctest CLI.
220+
print(f"Doctest caught exception: {op_fail}")
221+
traceback.print_exception(*op_fail.exc_info)
222+
223+
if n_total_fails > 0 and stop_on_failure:
224+
break
175225

176226
if verbose or n_total_fails > 0:
177227
# Print a final report
178228
msgs = ["", "=====", "run_doctest: FINAL REPORT"]
179229
if dry_run:
180230
msgs += ["(DRY RUN: no actual tests)"]
181231
elif stop_on_failure and n_total_fails > 0:
182-
msgs += ["(FAIL FAST: stopped at first target with errors)"]
232+
msgs += ["(FAIL FAST: stopped at first path with errors)"]
183233

184234
msgs += [
185235
f" paths tested = {n_paths_tested}",
@@ -197,57 +247,77 @@ def run_doctest_paths(
197247
return n_total_fails
198248

199249

250+
_help_extra_lines = "\n".join([
251+
"Notes:",
252+
" * file paths support glob patterns '* ? [] **' (** to include subdirectories)",
253+
" * N.B. use ** to include subdirectories",
254+
" * N.B. usually requires quotes, to avoid shell expansion",
255+
" * module paths do *not* support globs",
256+
" * but --recurse includes all submodules",
257+
" * \"--exclude\" patterns are a simple substring to match (not a glob/regexp)",
258+
"",
259+
"Examples:",
260+
" $ run_doctests \"docs/**/*.rst\" # test all document sources",
261+
" $ run_doctests \"docs/user*/**/*.rst\" -e detail # skip filepaths containing key string",
262+
" $ run_doctests -mr mymod # test module + all submodules",
263+
" $ run_doctests -mr mymod.util -e maths -e fun.err # skip module paths with substrings",
264+
" $ run_doctests -mr mymod -o verbose=true # make doctest print each test",
265+
])
266+
267+
200268
_parser = argparse.ArgumentParser(
201269
prog="run_doctests",
202-
description="Runs doctests in docs files, or docstrings in packages.",
270+
description="Run doctests in docs files, or docstrings in packages.",
271+
epilog=_help_extra_lines,
272+
formatter_class=argparse.RawDescriptionHelpFormatter,
203273
)
204274
_parser.add_argument(
205275
"-m", "--module", action="store_true",
206-
help="Paths are module paths (xx.yy.zz), instead of filepaths."
276+
help="paths are module paths (xx.yy.zz), instead of filepaths."
207277
)
208278
_parser.add_argument(
209279
"-r",
210-
"--recursive",
280+
"--recurse",
211281
action="store_true",
212-
help="If set, include submodules (only applies with -m).",
282+
help="include submodules (only applies with -m).",
213283
)
214284
_parser.add_argument(
215285
"-p",
216286
"--publiconly",
217287
action="store_true",
218-
help="If set, exclude private modules (only applies with -m and -r)",
288+
help="exclude module names beginning '_' (only applies with -m and -r)",
219289
)
220290
_parser.add_argument(
221291
"-e",
222292
"--exclude",
223293
action="append",
224-
help="Match fragments of paths to exclude.",
294+
help="exclude paths containing substring (may appear multiple times).",
225295
)
226296
_parser.add_argument(
227297
"-o",
228298
"--options",
229299
nargs="?",
230300
help=(
231-
"doctest function kwargs (string)"
232-
", e.g. \"report=False, raise_on_error=True, optionflags=8\"."
301+
"kwargs (Python) for doctest call"
302+
", e.g. \"raise_on_error=True,optionflags=8\"."
233303
),
234304
type=str,
235305
default="",
236306
)
237307
_parser.add_argument(
238-
"-v", "--verbose", action="store_true", help="Show details of each action."
308+
"-v", "--verbose", action="store_true", help="show details of each operation."
239309
)
240310
_parser.add_argument(
241311
"-d",
242312
"--dryrun",
243313
action="store_true",
244-
help="Only print the names of modules/files which *would* be tested.",
314+
help="only print names of modules/files which *would* be tested.",
245315
)
246316
_parser.add_argument(
247317
"-f",
248318
"--stop-on-fail",
249319
action="store_true",
250-
help="If set, stop at the first path with an error (else continue to test all).",
320+
help="stop at the first path with an error (else continue to test all).",
251321
)
252322
_parser.add_argument(
253323
"paths",
@@ -262,10 +332,10 @@ def parserargs_as_kwargs(args):
262332
return dict(
263333
paths=args.paths,
264334
paths_are_modules=args.module,
265-
recurse_modules=args.recursive,
335+
recurse_modules=args.recurse,
266336
include_private_modules=not args.publiconly,
267337
exclude_matches=args.exclude or [],
268-
option_kwargs=process_options(args.options, args.module),
338+
doctest_kwargs=process_options(args.options, args.module),
269339
verbose=args.verbose,
270340
dry_run=args.dryrun,
271341
stop_on_failure=args.stop_on_fail,
@@ -274,6 +344,10 @@ def parserargs_as_kwargs(args):
274344

275345
if __name__ == "__main__":
276346
args = _parser.parse_args(sys.argv[1:])
277-
kwargs = parserargs_as_kwargs(args)
278-
n_errs = run_doctest_paths(**kwargs)
279-
exit(1 if n_errs > 0 else 0)
347+
if not args.paths:
348+
_parser.print_help()
349+
else:
350+
kwargs = parserargs_as_kwargs(args)
351+
n_errs = run_doctest_paths(**kwargs)
352+
if n_errs > 0:
353+
exit(1)

0 commit comments

Comments
 (0)