1717STUB_HEADER_COMMENT = "# File generated with docstub"
1818
1919
20- def is_docstub_generated (path ):
20+ def is_docstub_generated (stub_path ):
2121 """Check if the stub file was generated by docstub.
2222
2323 Parameters
2424 ----------
25- path : Path
25+ stub_path : Path
26+ Path to a stub file.
2627
2728 Returns
2829 -------
2930 is_generated : bool
31+
32+ Examples
33+ --------
34+ >>> from pathlib import Path
35+ >>> from docstub import _version
36+ >>> is_docstub_generated(Path(_version.__file__).with_suffix(".pyi"))
37+ False
38+
39+ >>> is_docstub_generated(Path(__file__))
40+ Traceback (most recent call last):
41+ ...
42+ TypeError: expected stub file (ending with '.pyi'), ...
3043 """
31- assert path .suffix == ".pyi"
32- with path .open ("r" ) as fo :
44+ if stub_path .suffix != ".pyi" :
45+ raise TypeError (f"expected stub file (ending with '.pyi'), got { stub_path } " )
46+ with stub_path .open ("r" ) as fo :
3347 content = fo .read ()
3448 if re .match (f"^{ re .escape (STUB_HEADER_COMMENT )} " , content ):
3549 return True
3650 return False
3751
3852
39- def is_python_package (path ):
53+ def is_python_or_stub_file (path ):
54+ """Check whether `path` is a Python source file.
55+
56+ Parameters
57+ ----------
58+ path : Path
59+
60+ Returns
61+ -------
62+ is_python_or_stub_file : bool
63+
64+ See Also
65+ --------
66+ is_python_package_dir
67+
68+ Examples
69+ --------
70+ >>> from pathlib import Path
71+ >>> is_python_or_stub_file(Path(__file__))
72+ True
73+ >>> is_python_or_stub_file(Path(__file__).parent)
74+ False
4075 """
76+ return path .is_file () and path .suffix in (".py" , ".pyi" )
77+
78+
79+ def is_python_package_dir (path ):
80+ """Check whether `path` is a valid Python package and a directory.
81+
4182 Parameters
4283 ----------
4384 path : Path
@@ -46,14 +87,18 @@ def is_python_package(path):
4687 -------
4788 is_package : bool
4889
90+ See Also
91+ --------
92+ is_python_or_stub_file
93+
4994 Examples
5095 --------
5196 >>> from pathlib import Path
52- >>> is_python_package (Path(__file__))
97+ >>> is_python_package_dir (Path(__file__))
5398 False
54- >>> is_python_package (Path(__file__).parent)
99+ >>> is_python_package_dir (Path(__file__).parent)
55100 True
56- >>> is_python_package (Path(__file__).parent.parent)
101+ >>> is_python_package_dir (Path(__file__).parent.parent)
57102 False
58103 """
59104 has_init = (path / "__init__.py" ).is_file () or (path / "__init__.pyi" ).is_file ()
@@ -84,7 +129,7 @@ def find_package_root(path):
84129 root = root .parent
85130
86131 for _ in range (2 ** 16 ):
87- if not is_python_package (root ):
132+ if not is_python_package_dir (root ):
88133 logger .debug ("detected %s as the package root of %s" , root , path )
89134 return root
90135 root = root .parent
@@ -95,7 +140,7 @@ def find_package_root(path):
95140
96141@lru_cache (maxsize = 10 )
97142def glob_patterns_to_regex (patterns , relative_to = None ):
98- r"""Combine glob-style patterns into a single regex.
143+ r"""Combine glob-style patterns into a single regex [1] .
99144
100145 Parameters
101146 ----------
@@ -106,6 +151,10 @@ def glob_patterns_to_regex(patterns, relative_to=None):
106151 -------
107152 regex : re.Pattern | None
108153
154+ References
155+ ----------
156+ .. [1] https://docs.python.org/3/library/glob.html#glob.translate
157+
109158 Examples
110159 --------
111160 >>> from pathlib import Path
@@ -143,17 +192,59 @@ def prefix(pattern):
143192 return regex
144193
145194
146- def walk_python_package ( root_dir , * , ignore = () ):
195+ def _walk_source_package ( path , * , ignore_regex ):
147196 """Iterate source files in a Python package.
148197
198+ .. note::
199+ Inner function of :func:`walk_source_package`. See that function
200+ for more details.
201+
202+ Parameters
203+ ----------
204+ path : Path
205+ Root directory of a Python package. Can also be a single Python or stub
206+ file.
207+ ignore_regex : re.Pattern
208+ Don't yield files matching this regex-compiled glob-like pattern.
209+
210+ Yields
211+ ------
212+ source_path : Path
213+ Either a Python file or a stub file that takes precedence.
214+ """
215+ if ignore_regex and ignore_regex .match (str (path )):
216+ logger .info ("ignoring %s" , path )
217+ return
218+
219+ if is_python_package_dir (path ):
220+ for sub_path in path .iterdir ():
221+ yield from _walk_source_package (sub_path , ignore_regex = ignore_regex )
222+
223+ elif is_python_or_stub_file (path ):
224+ stub_path = path .with_suffix (".pyi" )
225+ if stub_path == path or not stub_path .is_file ():
226+ # If `path` is a stub file return it. If it is a regular Python
227+ # file, only return it if no corresponding stub file exists.
228+ yield path
229+
230+ elif path .is_dir ():
231+ logger .debug ("skipping directory %s which isn't a Python package" , path )
232+
233+ elif path .is_file ():
234+ logger .debug ("skipping non-Python file %s" , path )
235+
236+
237+ def walk_source_package (path , * , ignore = ()):
238+ """Iterate over a source package for docstub.
239+
149240 Given a Python package, yield the path of contained Python modules. If an
150241 alternate stub file already exists and isn't generated by docstub, it is
151242 returned instead.
152243
153244 Parameters
154245 ----------
155- root_dir : Path
156- Root directory of a Python package .
246+ path : Path
247+ A Python package, either a directory or a single file .
157248 ignore : Sequence[str], optional
158249 Don't yield files matching these glob-like patterns. The pattern is
159250 interpreted relative to the root of the Python package unless it starts
@@ -163,36 +254,61 @@ def walk_python_package(root_dir, *, ignore=()):
163254 Yields
164255 ------
165256 source_path : Path
166- Either a Python file or a stub file that takes precedence.
257+ Either a Python file or a stub file that takes precedence. Note that
258+ stub files generated by docstub itself are not returned.
259+
260+ Raises
261+ ------
262+ TypeError
263+ If `path` is not a valid Python package. Note that a single
264+ Python file is considered a "package".
265+
266+ See Also
267+ --------
268+ walk_source_and_targets
269+
270+ Examples
271+ --------
272+ >>> from pathlib import Path
273+ >>> this_file = Path(__file__)
274+
275+ Walk `path` to current file
276+ >>> package_files = sorted(walk_source_package(this_file))
277+ >>> len(package_files)
278+ 1
279+ >>> package_files[0].as_posix()
280+ '.../docstub/_path_utils.py'
281+
282+ Walk `path` to directory of current file
283+ >>> package_files = walk_source_package(this_file.parent)
284+ >>> sorted(package_files)
285+ [.../docstub/__init__.py'), ...]
286+
287+ Ignoring all files ending with '.py' will return nothing
288+ >>> next(walk_source_package(this_file.parent, ignore=("*.py")))
289+ Traceback (most recent call last):
290+ ...
291+ StopIteration
167292 """
168- package_root = find_package_root ( root_dir )
169- regex = glob_patterns_to_regex ( tuple ( ignore ), relative_to = package_root )
293+ if not is_python_package_dir ( path ) and not is_python_or_stub_file ( path ):
294+ raise TypeError ( f" { path } must be a Python file or package" )
170295
171- if regex and regex .match (str (root_dir )):
172- logger .info ("ignoring %s" , root_dir )
173- return
296+ regex = glob_patterns_to_regex (tuple (ignore ), relative_to = path )
174297
175- for path in root_dir .iterdir ():
176- if regex and regex .match (str (path .resolve ())):
177- logger .info ("ignoring %s" , path )
178- continue
179- if path .is_dir ():
180- if is_python_package (path ):
181- yield from walk_python_package (path , ignore = ignore )
182- else :
183- logger .debug ("skipping directory %s which isn't a Python package" , path )
184- continue
185-
186- assert path .is_file ()
187- suffix = path .suffix .lower ()
188-
189- if suffix == ".py" :
190- stub = path .with_suffix (".pyi" )
191- if stub .exists () and not is_docstub_generated (stub ):
192- # Non-generated stub file already exists and takes precedence
193- yield stub
194- else :
195- yield path
298+ if is_python_or_stub_file (path ):
299+ stub_file = path .with_suffix (".pyi" )
300+ if (
301+ stub_file != path
302+ and stub_file .is_file ()
303+ and not is_docstub_generated (stub_file )
304+ ):
305+ # Special case: `path` is a Python file for which a stub file
306+ # exists, we want to return that one while taking into account
307+ # `ignore` and other logic. A simple way to do so is to just pass
308+ # the stub file instead of `path`.
309+ path = stub_file
310+
311+ yield from _walk_source_package (path , ignore_regex = regex )
196312
197313
198314def walk_source_and_targets (root_path , target_dir , * , ignore = ()):
@@ -216,12 +332,37 @@ def walk_source_and_targets(root_path, target_dir, *, ignore=()):
216332 Either a Python file or a stub file that takes precedence.
217333 stub_path : Path
218334 Target stub file.
335+
336+ Raises
337+ ------
338+ TypeError
339+ If `root_path` is not a valid Python package. Note that a single
340+ Python file is considered a "package".
341+
342+ See Also
343+ --------
344+ walk_source_package
345+
346+ Examples
347+ --------
348+ >>> from pathlib import Path
349+ >>> current_root = Path(__file__).parent
350+ >>> sources_n_targets = sorted(
351+ ... walk_source_and_targets(current_root, target_dir=current_root)
352+ ... )
353+ >>> source_path, stub_path = sources_n_targets[0]
354+ >>> source_path.as_posix()
355+ '.../docstub/__init__.py'
356+ >>> stub_path.as_posix()
357+ '.../docstub/__init__.pyi'
358+ >>> stub_path.is_file()
359+ False
219360 """
220361 if root_path .is_file ():
221362 stub_path = target_dir / root_path .with_suffix (".pyi" ).name
222363 yield root_path , stub_path
223364 return
224365
225- for source_path in walk_python_package (root_path , ignore = ignore ):
366+ for source_path in walk_source_package (root_path , ignore = ignore ):
226367 stub_path = target_dir / source_path .with_suffix (".pyi" ).relative_to (root_path )
227368 yield source_path , stub_path
0 commit comments