17
17
STUB_HEADER_COMMENT = "# File generated with docstub"
18
18
19
19
20
- def is_docstub_generated (path ):
20
+ def is_docstub_generated (stub_path ):
21
21
"""Check if the stub file was generated by docstub.
22
22
23
23
Parameters
24
24
----------
25
- path : Path
25
+ stub_path : Path
26
+ Path to a stub file.
26
27
27
28
Returns
28
29
-------
29
30
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'), got .../docstub/_path_utils.py
30
43
"""
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 :
33
47
content = fo .read ()
34
48
if re .match (f"^{ re .escape (STUB_HEADER_COMMENT )} " , content ):
35
49
return True
36
50
return False
37
51
38
52
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
40
75
"""
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
+
41
82
Parameters
42
83
----------
43
84
path : Path
@@ -46,14 +87,18 @@ def is_python_package(path):
46
87
-------
47
88
is_package : bool
48
89
90
+ See Also
91
+ --------
92
+ is_python_or_stub_file
93
+
49
94
Examples
50
95
--------
51
96
>>> from pathlib import Path
52
- >>> is_python_package (Path(__file__))
97
+ >>> is_python_package_dir (Path(__file__))
53
98
False
54
- >>> is_python_package (Path(__file__).parent)
99
+ >>> is_python_package_dir (Path(__file__).parent)
55
100
True
56
- >>> is_python_package (Path(__file__).parent.parent)
101
+ >>> is_python_package_dir (Path(__file__).parent.parent)
57
102
False
58
103
"""
59
104
has_init = (path / "__init__.py" ).is_file () or (path / "__init__.pyi" ).is_file ()
@@ -84,7 +129,7 @@ def find_package_root(path):
84
129
root = root .parent
85
130
86
131
for _ in range (2 ** 16 ):
87
- if not is_python_package (root ):
132
+ if not is_python_package_dir (root ):
88
133
logger .debug ("detected %s as the package root of %s" , root , path )
89
134
return root
90
135
root = root .parent
@@ -95,7 +140,7 @@ def find_package_root(path):
95
140
96
141
@lru_cache (maxsize = 10 )
97
142
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] .
99
144
100
145
Parameters
101
146
----------
@@ -106,6 +151,10 @@ def glob_patterns_to_regex(patterns, relative_to=None):
106
151
-------
107
152
regex : re.Pattern | None
108
153
154
+ References
155
+ ----------
156
+ .. [1] https://docs.python.org/3/library/glob.html#glob.translate
157
+
109
158
Examples
110
159
--------
111
160
>>> from pathlib import Path
@@ -143,17 +192,59 @@ def prefix(pattern):
143
192
return regex
144
193
145
194
146
- def walk_python_package ( root_dir , * , ignore = () ):
195
+ def _walk_source_package ( path , * , ignore_regex ):
147
196
"""Iterate source files in a Python package.
148
197
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
+
149
240
Given a Python package, yield the path of contained Python modules. If an
150
241
alternate stub file already exists and isn't generated by docstub, it is
151
242
returned instead.
152
243
153
244
Parameters
154
245
----------
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 .
157
248
ignore : Sequence[str], optional
158
249
Don't yield files matching these glob-like patterns. The pattern is
159
250
interpreted relative to the root of the Python package unless it starts
@@ -163,36 +254,58 @@ def walk_python_package(root_dir, *, ignore=()):
163
254
Yields
164
255
------
165
256
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
+
274
+ Walk `path` to current file
275
+ >>> package_files = walk_source_package(Path(__file__))
276
+ >>> list(package_files)
277
+ [.../docstub/_path_utils.py')]
278
+
279
+ Walk `path` to directory of current file
280
+ >>> package_files = walk_source_package(Path(__file__).parent)
281
+ >>> sorted(package_files)
282
+ [.../docstub/__init__.py'), ...]
283
+
284
+ Ignoring all files ending with '.py' will return nothing
285
+ >>> next(walk_source_package(Path(__file__).parent, ignore=("*.py")))
286
+ Traceback (most recent call last):
287
+ ...
288
+ StopIteration
167
289
"""
168
- package_root = find_package_root ( root_dir )
169
- regex = glob_patterns_to_regex ( tuple ( ignore ), relative_to = package_root )
290
+ if not is_python_package_dir ( path ) and not is_python_or_stub_file ( path ):
291
+ raise TypeError ( f" { path } must be a Python file or package" )
170
292
171
- if regex and regex .match (str (root_dir )):
172
- logger .info ("ignoring %s" , root_dir )
173
- return
293
+ regex = glob_patterns_to_regex (tuple (ignore ), relative_to = path )
174
294
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
295
+ if is_python_or_stub_file (path ):
296
+ stub_file = path .with_suffix (".pyi" )
297
+ if (
298
+ stub_file != path
299
+ and stub_file .is_file ()
300
+ and not is_docstub_generated (stub_file )
301
+ ):
302
+ # Special case: `path` is a Python file for which a stub file
303
+ # exists, we want to return that one while taking into account
304
+ # `ignore` and other logic. A simple way to do so is to just pass
305
+ # the stub file instead of `path`.
306
+ path = stub_file
307
+
308
+ yield from _walk_source_package (path , ignore_regex = regex )
196
309
197
310
198
311
def walk_source_and_targets (root_path , target_dir , * , ignore = ()):
@@ -216,12 +329,36 @@ def walk_source_and_targets(root_path, target_dir, *, ignore=()):
216
329
Either a Python file or a stub file that takes precedence.
217
330
stub_path : Path
218
331
Target stub file.
332
+
333
+ Raises
334
+ ------
335
+ TypeError
336
+ If `root_path` is not a valid Python package. Note that a single
337
+ Python file is considered a "package".
338
+
339
+ See Also
340
+ --------
341
+ walk_source_package
342
+
343
+ Examples
344
+ --------
345
+ >>> from pathlib import Path
346
+ >>> current_root = Path(__file__).parent
347
+ >>> source_path, stub_path = next(
348
+ ... walk_source_and_targets(current_root, target_dir=current_root)
349
+ ... )
350
+ >>> source_path.as_posix()
351
+ '.../docstub/_cli.py'
352
+ >>> stub_path.as_posix()
353
+ '.../docstub/_cli.pyi'
354
+ >>> stub_path.is_file()
355
+ False
219
356
"""
220
357
if root_path .is_file ():
221
358
stub_path = target_dir / root_path .with_suffix (".pyi" ).name
222
359
yield root_path , stub_path
223
360
return
224
361
225
- for source_path in walk_python_package (root_path , ignore = ignore ):
362
+ for source_path in walk_source_package (root_path , ignore = ignore ):
226
363
stub_path = target_dir / source_path .with_suffix (".pyi" ).relative_to (root_path )
227
364
yield source_path , stub_path
0 commit comments