Skip to content

Commit 75d1362

Browse files
authored
Improve walking of the source directory (#68)
and properly test that behavior. Previously, interaction between `ignore=` and present stub files could have lead to unexpected edge cases (I don't exactly remember what).
1 parent cba3dc0 commit 75d1362

File tree

3 files changed

+276
-61
lines changed

3 files changed

+276
-61
lines changed

src/docstub/_cli.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@
1717
from ._config import Config
1818
from ._path_utils import (
1919
STUB_HEADER_COMMENT,
20-
walk_python_package,
2120
walk_source_and_targets,
21+
walk_source_package,
2222
)
2323
from ._stubs import Py2StubTransformer, try_format_stub
2424
from ._utils import ErrorReporter, GroupedErrorReporter, module_name_from_path
@@ -100,7 +100,7 @@ def _collect_type_info(root_path, *, ignore=()):
100100
type_prefixes = {}
101101

102102
if root_path.is_dir():
103-
for source_path in walk_python_package(root_path, ignore=ignore):
103+
for source_path in walk_source_package(root_path, ignore=ignore):
104104

105105
module = module_name_from_path(source_path)
106106
module = module.replace(".", "/")
@@ -228,8 +228,8 @@ def run(root_path, out_dir, config_paths, ignore, group_errors, allow_errors, ve
228228
root_path = Path(root_path)
229229
if root_path.is_file():
230230
logger.warning(
231-
"Running docstub on a single file is experimental. Relative imports "
232-
"or type references won't work."
231+
"Running docstub on a single file. Relative imports "
232+
"or type references outside this file won't work."
233233
)
234234

235235
config = _load_configuration(config_paths)

src/docstub/_path_utils.py

Lines changed: 182 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -17,27 +17,68 @@
1717
STUB_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)
97142
def 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

198314
def 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

Comments
 (0)