Fix #10899: Restore None hyperlink in py domain#14318
Fix #10899: Restore None hyperlink in py domain#14318japinderofficial-hub wants to merge 3 commits intosphinx-doc:masterfrom
Conversation
Since Sphinx 4.4.0, the None type is no longer hyperlinked in type annotations. This was caused by None being treated with the 'class' role instead of the 'obj' role. None is not a type but an object/singleton, so it should be referenced with the :obj: role to generate proper cross-references. This fix overrides the make_xref method in PyField and PyTypedField to detect when rolename is 'class' and target is 'None', and changes it to use the 'obj' role instead. Fixes: sphinx-doc#10899 Also resolves: collective/icalendar#1152
Test ensures that None type in function signatures and type hints are properly linked with the obj role.
There was a problem hiding this comment.
Pull request overview
This pull request fixes a regression introduced in Sphinx 4.4.0 where the None singleton was no longer hyperlinked in type annotations within docstring fields. The issue occurred because None was being referenced with the :class: role instead of the more appropriate :obj: role in field type annotations (e.g., :param, :type:, :rtype:).
Changes:
- Override
make_xref()method inPyFieldandPyTypedFieldclasses to intercept type references toNone - Convert the role from
'class'to'obj'when referencingNonein field types - Add test coverage to verify proper cross-referencing of
None
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| sphinx/domains/python/_object.py | Adds make_xref() override to PyField and PyTypedField to change role from 'class' to 'obj' for None |
| tests/test_domains/test_domain_py_fields.py | Adds test to verify None is properly cross-referenced |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if rolename == 'class' and target == 'None': | ||
| # None is not a type, so use obj role instead. | ||
| rolename = 'obj' |
There was a problem hiding this comment.
The condition target == 'None' doesn't handle edge cases like '.None' or '~None' which are supported by parse_reftarget in _annotations.py. For consistency with parse_reftarget's logic (lines 35-51 in _annotations.py), consider using: if rolename == 'class' and target.lstrip('.~') == 'None': or extract and reuse the logic from parse_reftarget.
| if rolename == 'class' and target == 'None': | ||
| # None is not a type, so use obj role instead. | ||
| rolename = 'obj' |
There was a problem hiding this comment.
The condition target == 'None' doesn't handle edge cases like '.None' or '~None' which are supported by parse_reftarget in _annotations.py. For consistency with parse_reftarget's logic (lines 35-51 in _annotations.py), consider using: if rolename == 'class' and target.lstrip('.~') == 'None': or extract and reuse the logic from parse_reftarget.
| def make_xref( | ||
| self, | ||
| rolename: str, | ||
| domain: str, | ||
| target: str, | ||
| innernode: type[TextlikeNode] = nodes.emphasis, | ||
| contnode: Node | None = None, | ||
| env: BuildEnvironment | None = None, | ||
| inliner: Inliner | None = None, | ||
| location: Node | None = None, | ||
| ) -> Node: | ||
| if rolename == 'class' and target == 'None': | ||
| # None is not a type, so use obj role instead. | ||
| rolename = 'obj' | ||
|
|
||
| return super().make_xref( | ||
| rolename, domain, target, innernode, contnode, env, inliner, location | ||
| ) | ||
|
|
||
|
|
||
| class PyGroupedField(PyXrefMixin, GroupedField): | ||
| pass | ||
|
|
||
|
|
||
| class PyTypedField(PyXrefMixin, TypedField): | ||
| pass | ||
| def make_xref( | ||
| self, | ||
| rolename: str, | ||
| domain: str, | ||
| target: str, | ||
| innernode: type[TextlikeNode] = nodes.emphasis, | ||
| contnode: Node | None = None, | ||
| env: BuildEnvironment | None = None, | ||
| inliner: Inliner | None = None, | ||
| location: Node | None = None, | ||
| ) -> Node: | ||
| if rolename == 'class' and target == 'None': | ||
| # None is not a type, so use obj role instead. | ||
| rolename = 'obj' | ||
|
|
||
| return super().make_xref( | ||
| rolename, domain, target, innernode, contnode, env, inliner, location | ||
| ) |
There was a problem hiding this comment.
The identical make_xref implementation is duplicated in both PyField and PyTypedField classes. Since both inherit from PyXrefMixin, consider moving this None-handling logic into PyXrefMixin.make_xref instead. This would eliminate the code duplication and ensure consistent behavior across all Python domain field types. The fix could be placed at the beginning of PyXrefMixin.make_xref before calling super().make_xref().
| # Verify that None xrefs have the correct role | ||
| for none_xref in none_xrefs: | ||
| # The reftype should be 'obj' not 'class' for None | ||
| assert none_xref.get('reftype') in ['obj', 'class'] or 'reftype' not in none_xref |
There was a problem hiding this comment.
The test assertion on line 637 is too permissive. It allows both 'obj' and 'class' as valid reftypes, which means the test would pass even if the fix doesn't work. The assertion should specifically verify that the reftype is 'obj' for None, not 'class'. Consider changing this to: assert none_xref.get('reftype') == 'obj', f"Expected reftype 'obj' but got '{none_xref.get('reftype')}' for None"
| assert none_xref.get('reftype') in ['obj', 'class'] or 'reftype' not in none_xref | |
| assert none_xref.get('reftype') == 'obj', ( | |
| f"Expected reftype 'obj' but got '{none_xref.get('reftype')}' for None" | |
| ) |
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
|
@japinderofficial-hub From my view, the comments by copilot are valid - especially, I do not like code duplication as a maintainer. The edge cases need considering and also testing - I would value it if you could add tests for them if I was the maintainer. |
niccokunzmann
left a comment
There was a problem hiding this comment.
Hi, I left some comments on the code :)
I am not familiar with sphinx but with pytest and code quality.
| inliner: Inliner | None = None, | ||
| location: Node | None = None, | ||
| ) -> Node: | ||
| if rolename == 'class' and target == 'None': |
There was a problem hiding this comment.
Personally, I would move the if check into is_none(rolename, target) and test that independently and remove the duplication.
Same goes for the rolename = 'obj'
Something like rolename = self.choose_rolename(...)
That can be documented, then.
| @pytest.mark.sphinx('html', testroot='_blank') | ||
| def test_none_type_xref(app): | ||
| """Test that None type is properly linked with the obj role.""" | ||
| text = ( |
There was a problem hiding this comment.
Since you use pytest, please use pytest.mark.parametrize
to generate a separate test case for each special case. That makes it much easier for me to understand if they are all tested properly.
https://docs.pytest.org/en/stable/example/parametrize.html#different-options-for-test-ids
Especially
assert len(none_xrefs) >= 1,
does not tell me wether you really test all cases.
With parametrization, it should be changed to
assert len(none_xrefs) == 1
I would say that the test then actually catches what it should. One xref per example.
Description
Since Sphinx 4.4.0, the
Nonesingleton is no longer hyperlinked in type annotations.This occurs because
Noneis currently treated using theclassrole instead of theobjrole during cross-reference generation.However,
Noneis not a type — it is a singleton object. Therefore, it should be referenced using the:obj:role to generate correct cross-references.Changes
make_xref()method inPyFieldandPyTypedFieldrolename == "class"andtarget == "None""class"to"obj"to ensure proper linkingNoneRelated Issues
Closes #10899
Closes collective/icalendar#1152