Skip to content

Commit 04bd0df

Browse files
authored
👌 Improve external role warnings (and revert object fallback) (#12193)
The key issue this commit seeks to address, is that existing tools / documentation often lead users to mistakenly use object types and not role names, a classic example being `function` not `func` Previously, the warning message for using e.g. `` external:function`target` `` (with `py` as the default domain), would be: ``` WARNING: role for external cross-reference not found: function ``` This gives no information to the user on how to fix the issue, even though there is actually quite an easy fix This commit adds logic to create more specific / helpful warning messages, e.g. ``` WARNING: role for external cross-reference not found in domains 'py', 'std': 'function' (perhaps you meant one of: 'py:func', 'py:obj') ``` This goes through the same original logic but, if the role is not found, it will look if the role name is actually an available object type on the domain(s), and then suggest its related roles. This commit also reverts #12133, which provided a (silent) fallback to auto convert an object type to a role name.
1 parent 2e05fd2 commit 04bd0df

File tree

4 files changed

+139
-41
lines changed

4 files changed

+139
-41
lines changed

‎CHANGES.rst‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,9 @@ Features added
3030
to annotate the return type of their ``setup`` function.
3131
Patch by Chris Sewell.
3232

33-
* #12133: Allow ``external`` roles to reference object types
34-
(rather than role names). Patch by Chris Sewell.
33+
* #12193: Improve ``external`` warnings for unknown roles.
34+
In particular, suggest related role names if an object type is mistakenly used.
35+
Patch by Chris Sewell.
3536

3637
* #12131: Added :confval:`show_warning_types` configuration option.
3738
Patch by Chris Sewell.

‎sphinx/ext/intersphinx.py‎

Lines changed: 126 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import sphinx
3535
from sphinx.addnodes import pending_xref
3636
from sphinx.builders.html import INVENTORY_FILENAME
37+
from sphinx.deprecation import _deprecation_warning
3738
from sphinx.errors import ExtensionError
3839
from sphinx.locale import _, __
3940
from sphinx.transforms.post_transforms import ReferencesResolver
@@ -533,17 +534,90 @@ def run(self) -> tuple[list[Node], list[system_message]]:
533534
assert self.name == self.orig_name.lower()
534535
inventory, name_suffix = self.get_inventory_and_name_suffix(self.orig_name)
535536
if inventory and not inventory_exists(self.env, inventory):
536-
logger.warning(__('inventory for external cross-reference not found: %s'),
537-
inventory, location=(self.env.docname, self.lineno))
537+
self._emit_warning(
538+
__('inventory for external cross-reference not found: %r'), inventory
539+
)
538540
return [], []
539541

540-
role_name = self.get_role_name(name_suffix)
542+
domain_name, role_name = self._get_domain_role(name_suffix)
543+
541544
if role_name is None:
542-
logger.warning(__('role for external cross-reference not found: %s'), name_suffix,
543-
location=(self.env.docname, self.lineno))
545+
self._emit_warning(
546+
__('invalid external cross-reference suffix: %r'), name_suffix
547+
)
544548
return [], []
545549

546-
result, messages = self.invoke_role(role_name)
550+
# attempt to find a matching role function
551+
role_func: RoleFunction | None
552+
553+
if domain_name is not None:
554+
# the user specified a domain, so we only check that
555+
if (domain := self.env.domains.get(domain_name)) is None:
556+
self._emit_warning(
557+
__('domain for external cross-reference not found: %r'), domain_name
558+
)
559+
return [], []
560+
if (role_func := domain.roles.get(role_name)) is None:
561+
msg = 'role for external cross-reference not found in domain %r: %r'
562+
if (
563+
object_types := domain.object_types.get(role_name)
564+
) is not None and object_types.roles:
565+
self._emit_warning(
566+
__(f'{msg} (perhaps you meant one of: %s)'),
567+
domain_name,
568+
role_name,
569+
self._concat_strings(object_types.roles),
570+
)
571+
else:
572+
self._emit_warning(__(msg), domain_name, role_name)
573+
return [], []
574+
575+
else:
576+
# the user did not specify a domain,
577+
# so we check first the default (if available) then standard domains
578+
domains: list[Domain] = []
579+
if default_domain := self.env.temp_data.get('default_domain'):
580+
domains.append(default_domain)
581+
if (
582+
std_domain := self.env.domains.get('std')
583+
) is not None and std_domain not in domains:
584+
domains.append(std_domain)
585+
586+
role_func = None
587+
for domain in domains:
588+
if (role_func := domain.roles.get(role_name)) is not None:
589+
domain_name = domain.name
590+
break
591+
592+
if role_func is None or domain_name is None:
593+
domains_str = self._concat_strings(d.name for d in domains)
594+
msg = 'role for external cross-reference not found in domains %s: %r'
595+
possible_roles: set[str] = set()
596+
for d in domains:
597+
if o := d.object_types.get(role_name):
598+
possible_roles.update(f'{d.name}:{r}' for r in o.roles)
599+
if possible_roles:
600+
msg = f'{msg} (perhaps you meant one of: %s)'
601+
self._emit_warning(
602+
__(msg),
603+
domains_str,
604+
role_name,
605+
self._concat_strings(possible_roles),
606+
)
607+
else:
608+
self._emit_warning(__(msg), domains_str, role_name)
609+
return [], []
610+
611+
result, messages = role_func(
612+
f'{domain_name}:{role_name}',
613+
self.rawtext,
614+
self.text,
615+
self.lineno,
616+
self.inliner,
617+
self.options,
618+
self.content,
619+
)
620+
547621
for node in result:
548622
if isinstance(node, pending_xref):
549623
node['intersphinx'] = True
@@ -573,57 +647,74 @@ def get_inventory_and_name_suffix(self, name: str) -> tuple[str | None, str]:
573647
msg = f'Malformed :external: role name: {name}'
574648
raise ValueError(msg)
575649

576-
def get_role_name(self, name: str) -> tuple[str, str] | None:
577-
"""Find (if any) the corresponding ``(domain, role name)`` for *name*.
578-
579-
The *name* can be either a role name (e.g., ``py:function`` or ``function``)
580-
given as ``domain:role`` or ``role``, or its corresponding object name
581-
(in this case, ``py:func`` or ``func``) given as ``domain:objname`` or ``objname``.
650+
def _get_domain_role(self, name: str) -> tuple[str | None, str | None]:
651+
"""Convert the *name* string into a domain and a role name.
582652
583-
If no domain is given, or the object/role name is not found for the requested domain,
584-
the 'std' domain is used.
653+
- If *name* contains no ``:``, return ``(None, name)``.
654+
- If *name* contains a single ``:``, the domain/role is split on this.
655+
- If *name* contains multiple ``:``, return ``(None, None)``.
585656
"""
586657
names = name.split(':')
587658
if len(names) == 1:
659+
return None, names[0]
660+
elif len(names) == 2:
661+
return names[0], names[1]
662+
else:
663+
return None, None
664+
665+
def _emit_warning(self, msg: str, /, *args: Any) -> None:
666+
logger.warning(
667+
msg,
668+
*args,
669+
type='intersphinx',
670+
subtype='external',
671+
location=(self.env.docname, self.lineno),
672+
)
673+
674+
def _concat_strings(self, strings: Iterable[str]) -> str:
675+
return ', '.join(f'{s!r}' for s in sorted(strings))
676+
677+
# deprecated methods
678+
679+
def get_role_name(self, name: str) -> tuple[str, str] | None:
680+
_deprecation_warning(
681+
__name__, f'{self.__class__.__name__}.get_role_name', '', remove=(9, 0)
682+
)
683+
names = name.split(':')
684+
if len(names) == 1:
685+
# role
588686
default_domain = self.env.temp_data.get('default_domain')
589687
domain = default_domain.name if default_domain else None
590-
name = names[0]
688+
role = names[0]
591689
elif len(names) == 2:
690+
# domain:role:
592691
domain = names[0]
593-
name = names[1]
692+
role = names[1]
594693
else:
595694
return None
596695

597-
if domain and (role := self.get_role_name_from_domain(domain, name)):
696+
if domain and self.is_existent_role(domain, role):
598697
return (domain, role)
599-
elif (role := self.get_role_name_from_domain('std', name)):
698+
elif self.is_existent_role('std', role):
600699
return ('std', role)
601700
else:
602701
return None
603702

604-
def is_existent_role(self, domain_name: str, role_or_obj_name: str) -> bool:
605-
"""Check if the given role or object exists in the given domain."""
606-
return self.get_role_name_from_domain(domain_name, role_or_obj_name) is not None
607-
608-
def get_role_name_from_domain(self, domain_name: str, role_or_obj_name: str) -> str | None:
609-
"""Check if the given role or object exists in the given domain,
610-
and return the related role name if it exists, otherwise return None.
611-
"""
703+
def is_existent_role(self, domain_name: str, role_name: str) -> bool:
704+
_deprecation_warning(
705+
__name__, f'{self.__class__.__name__}.is_existent_role', '', remove=(9, 0)
706+
)
612707
try:
613708
domain = self.env.get_domain(domain_name)
709+
return role_name in domain.roles
614710
except ExtensionError:
615-
return None
616-
if role_or_obj_name in domain.roles:
617-
return role_or_obj_name
618-
if (
619-
(role_name := domain.role_for_objtype(role_or_obj_name))
620-
and role_name in domain.roles
621-
):
622-
return role_name
623-
return None
711+
return False
624712

625713
def invoke_role(self, role: tuple[str, str]) -> tuple[list[Node], list[system_message]]:
626714
"""Invoke the role described by a ``(domain, role name)`` pair."""
715+
_deprecation_warning(
716+
__name__, f'{self.__class__.__name__}.invoke_role', '', remove=(9, 0)
717+
)
627718
domain = self.env.get_domain(role[0])
628719
if domain:
629720
role_func = domain.role(role[1])

‎tests/roots/test-ext-intersphinx-role/index.rst‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,14 @@
3535

3636

3737
- a function with explicit inventory:
38-
:external+inv:c:func:`CFunc` or :external+inv:c:function:`CFunc`
38+
:external+inv:c:func:`CFunc`
3939
- a class with explicit non-existing inventory, which also has upper-case in name:
4040
:external+invNope:cpp:class:`foo::Bar`
4141

42+
- An object type being mistakenly used instead of a role name:
43+
44+
- :external+inv:c:function:`CFunc`
45+
- :external+inv:function:`CFunc`
4246

4347
- explicit title:
4448
:external:cpp:type:`FoonsTitle <foons>`

‎tests/test_extensions/test_ext_intersphinx.py‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -555,9 +555,11 @@ def test_intersphinx_role(app, warning):
555555
warnings = strip_colors(warning.getvalue()).splitlines()
556556
index_path = app.srcdir / 'index.rst'
557557
assert warnings == [
558-
f'{index_path}:21: WARNING: role for external cross-reference not found: py:nope',
559-
f'{index_path}:28: WARNING: role for external cross-reference not found: nope',
560-
f'{index_path}:39: WARNING: inventory for external cross-reference not found: invNope',
558+
f"{index_path}:21: WARNING: role for external cross-reference not found in domain 'py': 'nope'",
559+
f"{index_path}:28: WARNING: role for external cross-reference not found in domains 'cpp', 'std': 'nope'",
560+
f"{index_path}:39: WARNING: inventory for external cross-reference not found: 'invNope'",
561+
f"{index_path}:44: WARNING: role for external cross-reference not found in domain 'c': 'function' (perhaps you meant one of: 'func', 'identifier', 'type')",
562+
f"{index_path}:45: WARNING: role for external cross-reference not found in domains 'cpp', 'std': 'function' (perhaps you meant one of: 'cpp:func', 'cpp:identifier', 'cpp:type')",
561563
f'{index_path}:9: WARNING: external py:mod reference target not found: module3',
562564
f'{index_path}:14: WARNING: external py:mod reference target not found: module10',
563565
f'{index_path}:19: WARNING: external py:meth reference target not found: inv:Foo.bar',

0 commit comments

Comments
 (0)