22import argparse
33import doctest
44import importlib
5+ import os
56import traceback
67from pathlib import Path
78import 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+
5795def 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 -----\n doctest.{ 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"\n ERRORS 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 \n ERROR 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"\n ERRORS 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 \n ERROR 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
275345if __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