docstub 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. docstub/__init__.py +7 -0
  2. docstub/__main__.py +4 -0
  3. docstub/_analysis.py +630 -0
  4. docstub/_app_generate_stubs.py +395 -0
  5. docstub/_cache.py +226 -0
  6. docstub/_cli.py +250 -0
  7. docstub/_cli_help.py +214 -0
  8. docstub/_concurrency.py +228 -0
  9. docstub/_config.py +101 -0
  10. docstub/_docstrings.py +856 -0
  11. docstub/_path_utils.py +378 -0
  12. docstub/_report.py +447 -0
  13. docstub/_stubs.py +1007 -0
  14. docstub/_utils.py +200 -0
  15. docstub/_vendored/__init__.py +0 -0
  16. docstub/_vendored/stdlib.py +231 -0
  17. docstub/_version.py +34 -0
  18. docstub/_version.pyi +8 -0
  19. docstub/config_template.toml +28 -0
  20. docstub/doctype.lark +144 -0
  21. docstub/numpy_config.toml +71 -0
  22. docstub-0.6.0.dist-info/METADATA +76 -0
  23. docstub-0.6.0.dist-info/RECORD +45 -0
  24. docstub-0.6.0.dist-info/WHEEL +5 -0
  25. docstub-0.6.0.dist-info/entry_points.txt +2 -0
  26. docstub-0.6.0.dist-info/licenses/LICENSE.txt +88 -0
  27. docstub-0.6.0.dist-info/top_level.txt +2 -0
  28. docstub-stubs/__init__.pyi +5 -0
  29. docstub-stubs/__main__.pyi +6 -0
  30. docstub-stubs/_analysis.pyi +88 -0
  31. docstub-stubs/_app_generate_stubs.pyi +50 -0
  32. docstub-stubs/_cache.pyi +49 -0
  33. docstub-stubs/_cli.pyi +41 -0
  34. docstub-stubs/_cli_help.pyi +44 -0
  35. docstub-stubs/_concurrency.pyi +48 -0
  36. docstub-stubs/_config.pyi +31 -0
  37. docstub-stubs/_docstrings.pyi +118 -0
  38. docstub-stubs/_path_utils.pyi +34 -0
  39. docstub-stubs/_report.pyi +81 -0
  40. docstub-stubs/_stubs.pyi +130 -0
  41. docstub-stubs/_utils.pyi +20 -0
  42. docstub-stubs/_vendored/__init__.pyi +1 -0
  43. docstub-stubs/_vendored/stdlib.pyi +29 -0
  44. docstub-stubs/_version.pyi +8 -0
  45. docstub-stubs/py.typed +0 -0
docstub/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ # Copyright (c) 2024 Lars Grüter. All rights reserved.
2
+
3
+ """Generate Python stub files (PYI) from docstrings."""
4
+
5
+ from ._version import __version__
6
+
7
+ __all__ = ["__version__"]
docstub/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from ._cli import cli
2
+
3
+ if __name__ == "__main__":
4
+ cli()
docstub/_analysis.py ADDED
@@ -0,0 +1,630 @@
1
+ """Collect type information."""
2
+
3
+ import builtins
4
+ import importlib
5
+ import json
6
+ import logging
7
+ import re
8
+ from dataclasses import asdict, dataclass
9
+ from functools import cache
10
+ from pathlib import Path
11
+
12
+ import libcst as cst
13
+ import libcst.matchers as cstm
14
+
15
+ from ._utils import accumulate_qualname, module_name_from_path, pyfile_checksum
16
+
17
+ logger: logging.Logger = logging.getLogger(__name__)
18
+
19
+
20
+ def _shared_leading_qualname(*qualnames):
21
+ """Identify the common leading parts between fully qualified names.
22
+
23
+ Parameters
24
+ ----------
25
+ *qualnames : tuple[str]
26
+
27
+ Returns
28
+ -------
29
+ shared : str
30
+ The names, still split by ".", that are common to the start of all given
31
+ `qualnames`. Empty string if nothing is common.
32
+
33
+ Examples
34
+ --------
35
+ >>> _shared_leading_qualname("foo.bar", "foo.baz")
36
+ 'foo'
37
+ >>> _shared_leading_qualname("foo.bar", "faa.baz")
38
+ ''
39
+ """
40
+ if len(qualnames) < 2:
41
+ raise ValueError("need more than two paths")
42
+ splits = (p.split(".") for p in qualnames)
43
+ shared = []
44
+ for names in zip(*splits, strict=False):
45
+ if all(names[0] == p for p in names):
46
+ shared.append(names[0])
47
+ else:
48
+ break
49
+ return ".".join(shared)
50
+
51
+
52
+ @dataclass(slots=True, frozen=True)
53
+ class PyImport:
54
+ """Information to construct an import statement for any Python object.
55
+
56
+ Attributes
57
+ ----------
58
+ from_ :
59
+ Dotted names after "from".
60
+ import_ :
61
+ Dotted names after "import".
62
+ as_ :
63
+ Name (without ".") after "as".
64
+ implicit :
65
+ Describes an object that doesn't need an import statement and is
66
+ implicitly available. This may be a builtin or an object that is known
67
+ to be available in a given scope. E.g. it may have already been
68
+ imported.
69
+
70
+ Examples
71
+ --------
72
+ >>> str(PyImport(from_="numpy", import_="uint8", as_="ui8"))
73
+ 'from numpy import uint8 as ui8'
74
+
75
+ >>> str(PyImport(implicit="int"))
76
+ Traceback (most recent call last):
77
+ ...
78
+ RuntimeError: cannot import implicit object: 'int'
79
+ """
80
+
81
+ import_: str | None = None
82
+ from_: str | None = None
83
+ as_: str | None = None
84
+ implicit: str | None = None
85
+
86
+ @classmethod
87
+ @cache
88
+ def typeshed_Incomplete(cls):
89
+ """Create import corresponding to ``from _typeshed import Incomplete``.
90
+
91
+ This type is not actually available at runtime and only intended to be
92
+ used in stub files [1]_.
93
+
94
+ Returns
95
+ -------
96
+ import : PyImport
97
+ The import corresponding to ``from _typeshed import Incomplete``.
98
+
99
+ References
100
+ ----------
101
+ .. [1] https://typing.readthedocs.io/en/latest/guides/writing_stubs.html#incomplete-stubs
102
+ """
103
+ import_ = cls(from_="_typeshed", import_="Incomplete")
104
+ return import_
105
+
106
+ def format_import(self, relative_to=None):
107
+ """Format import as valid Python import statement.
108
+
109
+ Parameters
110
+ ----------
111
+ relative_to : str, optional
112
+ If a dot-delimited module name is given, format the import relative
113
+ to it.
114
+
115
+ Returns
116
+ -------
117
+ formatted : str
118
+ """
119
+ if self.implicit:
120
+ msg = f"cannot import implicit object: {self.implicit!r}"
121
+ raise RuntimeError(msg)
122
+ out = f"import {self.import_}"
123
+
124
+ import_path = self.from_
125
+ if import_path:
126
+ if relative_to:
127
+ shared = _shared_leading_qualname(relative_to, import_path)
128
+ if shared == import_path:
129
+ import_path = "."
130
+ else:
131
+ import_path = self.from_.replace(shared, "")
132
+
133
+ out = f"from {import_path} {out}"
134
+ if self.as_:
135
+ out = f"{out} as {self.as_}"
136
+ return out
137
+
138
+ @property
139
+ def target(self) -> str:
140
+ if self.as_:
141
+ out = self.as_
142
+ elif self.import_:
143
+ out = self.import_
144
+ elif self.implicit:
145
+ # Account for scoped form "some_module_scope:target"
146
+ out = self.implicit.split(":")[-1]
147
+ else:
148
+ raise RuntimeError("cannot determine import target")
149
+ return out
150
+
151
+ @property
152
+ def has_import(self):
153
+ return self.implicit is None
154
+
155
+ def __post_init__(self):
156
+ if self.implicit is not None:
157
+ if (
158
+ self.import_ is not None
159
+ or self.as_ is not None
160
+ or self.from_ is not None
161
+ ):
162
+ raise ValueError("implicit import cannot contain import information")
163
+ elif self.import_ is None:
164
+ raise ValueError("must set at least one parameter: `import_` or `implicit`")
165
+ if self.as_ is not None and "." in self.as_:
166
+ raise ValueError("parameter `as_` can't contain a '.'")
167
+
168
+ def __repr__(self) -> str:
169
+ if self.implicit:
170
+ kwargs = f"implicit={self.implicit!r}"
171
+ else:
172
+ kwargs = [
173
+ f"from_={self.from_!r}" if self.from_ else None,
174
+ f"import_={self.import_!r}" if self.import_ else None,
175
+ f"as_={self.as_!r}" if self.as_ else None,
176
+ ]
177
+ kwargs = [arg for arg in kwargs if arg is not None]
178
+ kwargs = ", ".join(kwargs)
179
+ out = f"{type(self).__name__}({kwargs})"
180
+ return out
181
+
182
+ def __str__(self) -> str:
183
+ out = self.format_import()
184
+ return out
185
+
186
+
187
+ def _is_type(value):
188
+ """Check if value is a type.
189
+
190
+ Parameters
191
+ ----------
192
+ value : Any
193
+
194
+ Returns
195
+ -------
196
+ is_type : bool
197
+ """
198
+ # Checking for isinstance(..., type) isn't enough, some types such as
199
+ # typing.Literal don't pass that check. So combine with checking for a
200
+ # __class__ attribute. Not sure about edge cases!
201
+ is_type = isinstance(value, type) or hasattr(value, "__class__")
202
+ return is_type
203
+
204
+
205
+ def _builtin_types():
206
+ """Return known imports for all builtins in the current runtime.
207
+
208
+ Returns
209
+ -------
210
+ types : dict[str, PyImport]
211
+ """
212
+ known_builtins = set(dir(builtins))
213
+
214
+ types = {}
215
+ for name in known_builtins:
216
+ if name.startswith("_"):
217
+ continue
218
+ value = getattr(builtins, name)
219
+ if not _is_type(value):
220
+ continue
221
+ types[name] = PyImport(implicit=name)
222
+
223
+ return types
224
+
225
+
226
+ def _runtime_types_in_module(module_name):
227
+ """Return types of a module in the current runtime.
228
+
229
+ Parameters
230
+ ----------
231
+ module_name : str
232
+
233
+ Returns
234
+ -------
235
+ types : dict[str, PyImport]
236
+ """
237
+ module = importlib.import_module(module_name)
238
+ types = {}
239
+ for name in module.__all__:
240
+ if name.startswith("_"):
241
+ continue
242
+ value = getattr(module, name)
243
+ if not _is_type(value):
244
+ continue
245
+
246
+ py_import = PyImport(from_=module_name, import_=name)
247
+ types[name] = py_import
248
+ types[f"{module_name}.{name}"] = py_import
249
+
250
+ return types
251
+
252
+
253
+ def common_known_types():
254
+ """Return commonly supported types.
255
+
256
+ This includes builtin types, and types from the `typing` or
257
+ `collections.abc` module.
258
+
259
+ Returns
260
+ -------
261
+ py_imports : dict[str, PyImport]
262
+
263
+ Examples
264
+ --------
265
+ >>> types = common_known_types()
266
+ >>> types["str"]
267
+ PyImport(implicit='str')
268
+ >>> types["Iterable"]
269
+ PyImport(from_='collections.abc', import_='Iterable')
270
+ >>> types["collections.abc.Iterable"]
271
+ PyImport(from_='collections.abc', import_='Iterable')
272
+ """
273
+ types = _builtin_types()
274
+ types |= _runtime_types_in_module("typing")
275
+ # Overrides containers from typing
276
+ types |= _runtime_types_in_module("collections.abc")
277
+ types |= _runtime_types_in_module("types")
278
+ return types
279
+
280
+
281
+ class TypeCollector(cst.CSTVisitor):
282
+ """Collect types from a given Python file.
283
+
284
+ Examples
285
+ --------
286
+ >>> types, prefixes = TypeCollector.collect(__file__)
287
+ >>> types[f"{__name__}.TypeCollector"]
288
+ PyImport(from_='docstub._analysis', import_='TypeCollector')
289
+
290
+ >>> from pathlib import Path
291
+ >>> from docstub._utils import module_name_from_path
292
+ >>> module = module_name_from_path(Path(__file__))
293
+ >>> prefixes[f"{module}:logging"]
294
+ PyImport(implicit='...:logging')
295
+ """
296
+
297
+ class ImportSerializer:
298
+ """Implements the `FuncSerializer` protocol to cache `TypeCollector.collect`.
299
+
300
+ Attributes
301
+ ----------
302
+ suffix : ClassVar[str]
303
+ encoding : ClassVar[str]
304
+ """
305
+
306
+ suffix = ".json"
307
+ encoding = "utf-8"
308
+
309
+ def hash_args(self, path: Path) -> str:
310
+ """Compute a unique hash from the path passed to `TypeCollector.collect`."""
311
+ key = pyfile_checksum(path)
312
+ return key
313
+
314
+ def serialize(self, data):
315
+ """Serialize results from `TypeCollector.collect`.
316
+
317
+ Parameters
318
+ ----------
319
+ data : tuple[dict[str, PyImport], dict[str, PyImport]]
320
+
321
+ Returns
322
+ -------
323
+ raw : bytes
324
+ """
325
+ primitives = {}
326
+ for name, table in zip(["types", "type_prefixes"], data, strict=False):
327
+ primitives[name] = {key: asdict(imp) for key, imp in table.items()}
328
+ raw = json.dumps(primitives, separators=(",", ":"), indent=1).encode(
329
+ self.encoding
330
+ )
331
+ return raw
332
+
333
+ def deserialize(self, raw):
334
+ """Deserialize results from `TypeCollector.collect`.
335
+
336
+ Parameters
337
+ ----------
338
+ raw : bytes
339
+
340
+ Returns
341
+ -------
342
+ types : dict[str, PyImport]
343
+ type_prefixes : dict[str, PyImport]
344
+ """
345
+ primitives = json.loads(raw.decode(self.encoding))
346
+
347
+ def deserialize_table(table):
348
+ return {key: PyImport(**kw) for key, kw in table.items()}
349
+
350
+ types = deserialize_table(primitives["types"])
351
+ type_prefixes = deserialize_table(primitives["type_prefixes"])
352
+ return types, type_prefixes
353
+
354
+ @classmethod
355
+ def collect(cls, file):
356
+ """Collect importable type annotations in given file.
357
+
358
+ Parameters
359
+ ----------
360
+ file : Path
361
+
362
+ Returns
363
+ -------
364
+ types : dict[str, PyImport]
365
+ type_prefixes : dict[str, PyImport]
366
+ """
367
+ file = Path(file)
368
+ with file.open("r") as fo:
369
+ source = fo.read()
370
+
371
+ tree = cst.parse_module(source)
372
+ collector = cls(module_name=module_name_from_path(file))
373
+ tree.visit(collector)
374
+ return collector.types, collector.type_prefixes
375
+
376
+ def __init__(self, *, module_name):
377
+ """Initialize type collector.
378
+
379
+ Parameters
380
+ ----------
381
+ module_name : str
382
+ """
383
+ self.module_name = module_name
384
+ self._stack = []
385
+ self.types = {}
386
+ self.type_prefixes = {}
387
+
388
+ def visit_ClassDef(self, node: cst.ClassDef) -> bool:
389
+ self._stack.append(node.name.value)
390
+ self._collect_type_annotation(self._stack)
391
+ return True
392
+
393
+ def leave_ClassDef(self, original_node: cst.ClassDef) -> None:
394
+ self._stack.pop()
395
+
396
+ def visit_FunctionDef(self, node: cst.FunctionDef) -> bool:
397
+ return False
398
+
399
+ def visit_TypeAlias(self, node: cst.TypeAlias) -> bool:
400
+ """Collect type alias with 3.12 syntax."""
401
+ stack = [*self._stack, node.name.value]
402
+ self._collect_type_annotation(stack)
403
+ return False
404
+
405
+ def visit_AnnAssign(self, node: cst.AnnAssign) -> bool:
406
+ """Collect type alias annotated with `TypeAlias`."""
407
+ is_type_alias = cstm.matches(
408
+ node,
409
+ cstm.AnnAssign(
410
+ annotation=cstm.Annotation(annotation=cstm.Name(value="TypeAlias"))
411
+ ),
412
+ )
413
+ if is_type_alias and node.value is not None:
414
+ names = cstm.findall(node.target, cstm.Name())
415
+ assert len(names) == 1
416
+ stack = [*self._stack, names[0].value]
417
+ self._collect_type_annotation(stack)
418
+ return False
419
+
420
+ def visit_ImportFrom(self, node: cst.ImportFrom) -> bool:
421
+ """Collect "from import" targets as usable types."""
422
+ if cstm.matches(node.names, cstm.ImportStar()):
423
+ return False
424
+
425
+ if node.module:
426
+ from_names = cstm.findall(node.module, cstm.Name())
427
+ from_names = [n.value for n in from_names]
428
+ else:
429
+ from_names = []
430
+
431
+ for import_alias in node.names:
432
+ asname = import_alias.evaluated_alias
433
+ name = import_alias.evaluated_name
434
+
435
+ if not node.relative:
436
+ key = ".".join([*from_names, name])
437
+ py_import = PyImport(from_=".".join(from_names), import_=name)
438
+ self.types[key] = py_import
439
+
440
+ scoped_key = f"{self.module_name}:{asname or name}"
441
+ scoped_import = PyImport(implicit=scoped_key)
442
+ self.types[scoped_key] = scoped_import
443
+
444
+ return False
445
+
446
+ def visit_Import(self, node: cst.Import) -> bool:
447
+ for import_alias in node.names:
448
+ asname = import_alias.evaluated_alias
449
+ name = import_alias.evaluated_name
450
+ scoped_key = f"{self.module_name}:{asname or name}"
451
+ py_import = PyImport(implicit=scoped_key)
452
+ self.type_prefixes[scoped_key] = py_import
453
+
454
+ return False
455
+
456
+ def _collect_type_annotation(self, stack):
457
+ """Collect an importable type annotation.
458
+
459
+ Parameters
460
+ ----------
461
+ stack : Iterable[str]
462
+ A list of names that form the path to the collected type.
463
+ """
464
+ qualname = ".".join([self.module_name, *stack])
465
+ py_import = PyImport(from_=self.module_name, import_=stack[0])
466
+ self.types[qualname] = py_import
467
+
468
+
469
+ class TypeMatcher:
470
+ """Match strings to collected type information.
471
+
472
+ Attributes
473
+ ----------
474
+ types : dict[str, PyImport]
475
+ type_prefixes : dict[str, PyImport]
476
+ type_nicknames : dict[str, str]
477
+ successful_queries : int
478
+ unknown_qualnames : list
479
+ current_file : Path | None
480
+
481
+ Examples
482
+ --------
483
+ >>> from docstub._analysis import TypeMatcher, common_known_types
484
+ >>> db = TypeMatcher()
485
+ >>> db.match("Any")
486
+ ('Any', PyImport(from_='typing', import_='Any'))
487
+ """
488
+
489
+ def __init__(
490
+ self,
491
+ *,
492
+ types=None,
493
+ type_prefixes=None,
494
+ type_nicknames=None,
495
+ ):
496
+ """
497
+ Parameters
498
+ ----------
499
+ types : dict[str, PyImport]
500
+ type_prefixes : dict[str, PyImport]
501
+ type_nicknames : dict[str, str]
502
+ """
503
+ self.types = common_known_types() | (types or {})
504
+ self.type_prefixes = type_prefixes or {}
505
+ self.type_nicknames = type_nicknames or {}
506
+
507
+ self.stats = {
508
+ "matched_type_names": 0,
509
+ "unknown_type_names": [],
510
+ }
511
+
512
+ self.current_file = None
513
+
514
+ def _resolve_nickname(self, name):
515
+ """Return intended name if `name` is a nickname.
516
+
517
+ Parameters
518
+ ----------
519
+ name : str
520
+
521
+ Returns
522
+ -------
523
+ resolved : str
524
+ """
525
+ original = name
526
+ resolved = name
527
+ for _ in range(1000):
528
+ name = self.type_nicknames.get(name)
529
+ if name is None:
530
+ break
531
+ resolved = name
532
+ else:
533
+ logger.warning(
534
+ "Reached limit while resolving nicknames for %r in %s, using %r",
535
+ original,
536
+ self.current_file or "<file not known>",
537
+ resolved,
538
+ )
539
+ return resolved
540
+
541
+ def match(self, search):
542
+ """Search for a known annotation name.
543
+
544
+ Parameters
545
+ ----------
546
+ search : str
547
+ current_module : Path, optional
548
+
549
+ Returns
550
+ -------
551
+ type_name : str | None
552
+ py_import : PyImport | None
553
+ """
554
+ original_search = search
555
+ type_name = None
556
+ py_import = None
557
+
558
+ module = module_name_from_path(self.current_file) if self.current_file else None
559
+
560
+ search = self._resolve_nickname(search)
561
+
562
+ if search.startswith("~."):
563
+ # Sphinx like matching with abbreviated name
564
+ pattern = search.replace(".", r"\.")
565
+ pattern = pattern.replace("~", ".*")
566
+ regex = re.compile(pattern + "$")
567
+ # Might be slow, but works for now
568
+ matches = {
569
+ key: value
570
+ for key, value in self.types.items()
571
+ if regex.match(key)
572
+ if ":" not in key
573
+ }
574
+ if len(matches) > 1:
575
+ shortest_key = sorted(matches.keys(), key=lambda x: len(x))[0]
576
+ py_import = matches[shortest_key]
577
+ type_name = shortest_key
578
+ logger.warning(
579
+ "%r (original %r) in %s matches multiple types %r, using %r",
580
+ search,
581
+ original_search,
582
+ self.current_file or "<file?>",
583
+ matches.keys(),
584
+ shortest_key,
585
+ )
586
+ elif len(matches) == 1:
587
+ type_name, py_import = matches.popitem()
588
+ else:
589
+ search = search[2:]
590
+ logger.debug(
591
+ "couldn't match %r in %s",
592
+ search,
593
+ self.current_file or "<file not known>",
594
+ )
595
+
596
+ if py_import is None and module:
597
+ # Look for matching type in current module
598
+ py_import = self.types.get(f"{module}:{search}")
599
+ py_import = self.types.get(f"{module}.{search}", py_import)
600
+ if py_import:
601
+ type_name = search
602
+
603
+ if py_import is None and search in self.types:
604
+ type_name = search
605
+ py_import = self.types[search]
606
+
607
+ if py_import is None:
608
+ # Try a subset of the qualname (first 'a.b.c', then 'a.b' and 'a')
609
+ for partial_qualname in reversed(accumulate_qualname(search)):
610
+ py_import = self.type_prefixes.get(f"{module}:{partial_qualname}")
611
+ py_import = self.type_prefixes.get(partial_qualname, py_import)
612
+ if py_import:
613
+ type_name = search
614
+ break
615
+
616
+ if (
617
+ py_import is not None
618
+ and type_name is not None
619
+ and type_name != py_import.target
620
+ and not type_name.startswith(py_import.target)
621
+ ):
622
+ # Ensure that the annotation matches the import target
623
+ type_name = type_name[type_name.find(py_import.target) :]
624
+
625
+ if type_name is not None:
626
+ self.stats["matched_type_names"] += 1
627
+ else:
628
+ self.stats["unknown_type_names"].append(search)
629
+
630
+ return type_name, py_import