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.
- docstub/__init__.py +7 -0
- docstub/__main__.py +4 -0
- docstub/_analysis.py +630 -0
- docstub/_app_generate_stubs.py +395 -0
- docstub/_cache.py +226 -0
- docstub/_cli.py +250 -0
- docstub/_cli_help.py +214 -0
- docstub/_concurrency.py +228 -0
- docstub/_config.py +101 -0
- docstub/_docstrings.py +856 -0
- docstub/_path_utils.py +378 -0
- docstub/_report.py +447 -0
- docstub/_stubs.py +1007 -0
- docstub/_utils.py +200 -0
- docstub/_vendored/__init__.py +0 -0
- docstub/_vendored/stdlib.py +231 -0
- docstub/_version.py +34 -0
- docstub/_version.pyi +8 -0
- docstub/config_template.toml +28 -0
- docstub/doctype.lark +144 -0
- docstub/numpy_config.toml +71 -0
- docstub-0.6.0.dist-info/METADATA +76 -0
- docstub-0.6.0.dist-info/RECORD +45 -0
- docstub-0.6.0.dist-info/WHEEL +5 -0
- docstub-0.6.0.dist-info/entry_points.txt +2 -0
- docstub-0.6.0.dist-info/licenses/LICENSE.txt +88 -0
- docstub-0.6.0.dist-info/top_level.txt +2 -0
- docstub-stubs/__init__.pyi +5 -0
- docstub-stubs/__main__.pyi +6 -0
- docstub-stubs/_analysis.pyi +88 -0
- docstub-stubs/_app_generate_stubs.pyi +50 -0
- docstub-stubs/_cache.pyi +49 -0
- docstub-stubs/_cli.pyi +41 -0
- docstub-stubs/_cli_help.pyi +44 -0
- docstub-stubs/_concurrency.pyi +48 -0
- docstub-stubs/_config.pyi +31 -0
- docstub-stubs/_docstrings.pyi +118 -0
- docstub-stubs/_path_utils.pyi +34 -0
- docstub-stubs/_report.pyi +81 -0
- docstub-stubs/_stubs.pyi +130 -0
- docstub-stubs/_utils.pyi +20 -0
- docstub-stubs/_vendored/__init__.pyi +1 -0
- docstub-stubs/_vendored/stdlib.pyi +29 -0
- docstub-stubs/_version.pyi +8 -0
- docstub-stubs/py.typed +0 -0
docstub/__init__.py
ADDED
docstub/__main__.py
ADDED
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
|