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'), ...
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,61 @@ 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
+ >>> 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
167
292
"""
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" )
170
295
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 )
174
297
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 )
196
312
197
313
198
314
def walk_source_and_targets (root_path , target_dir , * , ignore = ()):
@@ -216,12 +332,37 @@ def walk_source_and_targets(root_path, target_dir, *, ignore=()):
216
332
Either a Python file or a stub file that takes precedence.
217
333
stub_path : Path
218
334
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
219
360
"""
220
361
if root_path .is_file ():
221
362
stub_path = target_dir / root_path .with_suffix (".pyi" ).name
222
363
yield root_path , stub_path
223
364
return
224
365
225
- for source_path in walk_python_package (root_path , ignore = ignore ):
366
+ for source_path in walk_source_package (root_path , ignore = ignore ):
226
367
stub_path = target_dir / source_path .with_suffix (".pyi" ).relative_to (root_path )
227
368
yield source_path , stub_path
0 commit comments