Skip to content

Commit d2a1dfd

Browse files
committed
Handle nested type nicknames
1 parent c0754c0 commit d2a1dfd

4 files changed

Lines changed: 87 additions & 22 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,8 @@ run.source = ["docstub"]
119119
".*maintenance.*" = "Maintenance"
120120

121121

122-
[tool.docstub.type_nicknames]
123-
Path = "pathlib.Path"
122+
[tool.docstub.types]
123+
Path = "pathlib"
124124

125125

126126
[tool.mypy]

src/docstub/_analysis.py

Lines changed: 48 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -504,27 +504,57 @@ def __init__(
504504

505505
self.current_file = None
506506

507-
def match(self, search_name):
507+
def _resolve_nickname(self, name):
508+
"""Return intended name if `name` is a nickname.
509+
510+
Parameters
511+
----------
512+
name : str
513+
514+
Returns
515+
-------
516+
resolved : str
517+
"""
518+
original = name
519+
resolved = name
520+
for _ in range(1000):
521+
name = self.type_nicknames.get(name)
522+
if name is None:
523+
break
524+
resolved = name
525+
else:
526+
logger.warning(
527+
"reached limit while resolving nicknames for %r in %s, using %r",
528+
original,
529+
self.current_file or "<file not known>",
530+
resolved,
531+
)
532+
return resolved
533+
534+
def match(self, search):
508535
"""Search for a known annotation name.
509536
510537
Parameters
511538
----------
512-
search_name : str
539+
search : str
513540
current_module : Path, optional
514541
515542
Returns
516543
-------
517544
type_name : str | None
518545
py_import : PyImport | None
519546
"""
547+
original_search = search
520548
type_name = None
521549
py_import = None
522550

523551
module = module_name_from_path(self.current_file) if self.current_file else None
524552

525-
if search_name.startswith("~."):
553+
search = self._resolve_nickname(search)
554+
555+
if search.startswith("~."):
526556
# Sphinx like matching with abbreviated name
527-
pattern = search_name.replace(".", r"\.")
557+
pattern = search.replace(".", r"\.")
528558
pattern = pattern.replace("~", ".*")
529559
regex = re.compile(pattern + "$")
530560
# Might be slow, but works for now
@@ -539,43 +569,41 @@ def match(self, search_name):
539569
py_import = matches[shortest_key]
540570
type_name = shortest_key
541571
logger.warning(
542-
"%r in %s matches multiple types %r, using %r",
543-
search_name,
572+
"%r (original %r) in %s matches multiple types %r, using %r",
573+
search,
574+
original_search,
544575
self.current_file or "<file not known>",
545576
matches.keys(),
546577
shortest_key,
547578
)
548579
elif len(matches) == 1:
549580
type_name, py_import = matches.popitem()
550581
else:
551-
search_name = search_name[2:]
582+
search = search[2:]
552583
logger.debug(
553584
"couldn't match %r in %s",
554-
search_name,
585+
search,
555586
self.current_file or "<file not known>",
556587
)
557588

558-
# Replace alias
559-
search_name = self.type_nicknames.get(search_name, search_name)
560-
561589
if py_import is None and module:
562590
# Look for matching type in current module
563-
py_import = self.types.get(f"{module}:{search_name}")
564-
py_import = self.types.get(f"{module}.{search_name}", py_import)
591+
py_import = self.types.get(f"{module}:{search}")
592+
py_import = self.types.get(f"{module}.{search}", py_import)
565593
if py_import:
566-
type_name = search_name
594+
type_name = search
567595

568-
if py_import is None and search_name in self.types:
569-
type_name = search_name
570-
py_import = self.types[search_name]
596+
if py_import is None and search in self.types:
597+
type_name = search
598+
py_import = self.types[search]
571599

572600
if py_import is None:
573601
# Try a subset of the qualname (first 'a.b.c', then 'a.b' and 'a')
574-
for partial_qualname in reversed(accumulate_qualname(search_name)):
602+
for partial_qualname in reversed(accumulate_qualname(search)):
575603
py_import = self.type_prefixes.get(f"{module}:{partial_qualname}")
576604
py_import = self.type_prefixes.get(partial_qualname, py_import)
577605
if py_import:
578-
type_name = search_name
606+
type_name = search
579607
break
580608

581609
if (
@@ -590,6 +618,6 @@ def match(self, search_name):
590618
if type_name is not None:
591619
self.successful_queries += 1
592620
else:
593-
self.unknown_qualnames.append(search_name)
621+
self.unknown_qualnames.append(search)
594622

595623
return type_name, py_import

src/docstub/_cli.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,11 +236,14 @@ def run(root_path, out_dir, config_paths, ignore, group_errors, allow_errors, ve
236236
config = config.merge(Config(ignore_files=list(ignore)))
237237

238238
types, type_prefixes = _collect_type_info(root_path, ignore=config.ignore_files)
239+
240+
# Add declared types from configuration
239241
types |= {
240242
type_name: PyImport(from_=module, import_=type_name)
241243
for type_name, module in config.types.items()
242244
}
243245

246+
# Add declared type prefixes from configuration
244247
type_prefixes |= {
245248
prefix: (
246249
PyImport(import_=module, as_=prefix)

tests/test_analysis.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,3 +319,37 @@ def test_scoped_type_prefix(self, module_factory):
319319
type_name, py_import = matcher.match("cal.January")
320320
assert type_name == "cal.January"
321321
assert py_import == PyImport(implicit="sub.module:cal")
322+
323+
def test_nested_nicknames(self, caplog):
324+
types = {
325+
"Foo": PyImport(implicit="Foo"),
326+
"Bar": PyImport(implicit="Bar"),
327+
}
328+
type_nicknames = {
329+
"Foo": "~.Baz",
330+
"~.Baz": "B.i.k",
331+
"B.i.k": "Bar",
332+
}
333+
matcher = TypeMatcher(types=types, type_nicknames=type_nicknames)
334+
335+
type_name, py_import = matcher.match("Foo")
336+
assert type_name == "Bar"
337+
assert py_import == PyImport(implicit="Bar")
338+
339+
def test_nickname_infinite_loop(self, caplog):
340+
types = {
341+
"Foo": PyImport(implicit="Foo"),
342+
"Bar": PyImport(implicit="Bar"),
343+
}
344+
type_nicknames = {
345+
"Foo": "Bar",
346+
"Bar": "Foo",
347+
}
348+
matcher = TypeMatcher(types=types, type_nicknames=type_nicknames)
349+
350+
type_name, py_import = matcher.match("Foo")
351+
assert len(caplog.records) == 1
352+
assert "reached limit while resolving nicknames" in caplog.text
353+
354+
assert type_name == "Foo"
355+
assert py_import == PyImport(implicit="Foo")

0 commit comments

Comments
 (0)