From 6c356a8bd65c5b0a093e9799fc92d43fe3fbd812 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Tue, 11 Feb 2025 12:41:26 +0100 Subject: [PATCH 1/7] Fix parsing of PEP-695 functions --- sphinx/domains/python/_object.py | 2 +- sphinx/ext/autodoc/__init__.py | 2 +- tests/test_domains/test_domain_py.py | 45 ++++++++++++++++------- tests/test_extensions/test_ext_autodoc.py | 22 +++++++++++ 4 files changed, 55 insertions(+), 16 deletions(-) diff --git a/sphinx/domains/python/_object.py b/sphinx/domains/python/_object.py index 1ed8dc168f0..88d51fd6ada 100644 --- a/sphinx/domains/python/_object.py +++ b/sphinx/domains/python/_object.py @@ -41,7 +41,7 @@ py_sig_re = re.compile( r"""^ ([\w.]*\.)? # class name(s) (\w+) \s* # thing name - (?: \[\s*(.*)\s*])? # optional: type parameters list + (?: \[\s*(.*?)\s*])? # optional: type parameters list (?: \(\s*(.*)\s*\) # optional: arguments (?:\s* -> \s* (.*))? # return annotation )? $ # and nothing more diff --git a/sphinx/ext/autodoc/__init__.py b/sphinx/ext/autodoc/__init__.py index fa961f4e109..67a6426ada0 100644 --- a/sphinx/ext/autodoc/__init__.py +++ b/sphinx/ext/autodoc/__init__.py @@ -66,7 +66,7 @@ r"""^ ([\w.]+::)? # explicit module name ([\w.]+\.)? # module and/or class name(s) (\w+) \s* # thing name - (?: \[\s*(.*)\s*])? # optional: type parameters list + (?: \[\s*(.*?)\s*])? # optional: type parameters list (?: \((.*)\) # optional: arguments (?:\s* -> \s* (.*))? # return annotation )? $ # and nothing more diff --git a/tests/test_domains/test_domain_py.py b/tests/test_domains/test_domain_py.py index 5790ca1b38c..070b6b71a91 100644 --- a/tests/test_domains/test_domain_py.py +++ b/tests/test_domains/test_domain_py.py @@ -50,23 +50,28 @@ def parse(sig): def test_function_signatures(): - rv = parse('func(a=1) -> int object') - assert rv == '(a=1)' - - rv = parse('func(a=1, [b=None])') - assert rv == '(a=1, [b=None])' - - rv = parse('func(a=1[, b=None])') - assert rv == '(a=1, [b=None])' - rv = parse("compile(source : string, filename, symbol='file')") assert rv == "(source : string, filename, symbol='file')" - rv = parse('func(a=[], [b=None])') - assert rv == '(a=[], [b=None])' - - rv = parse('func(a=[][, b=None])') - assert rv == '(a=[], [b=None])' + for params, expect in [ + ('(a=1)', '(a=1)'), + ('(a: int = 1)', '(a: int = 1)'), + ('(a=1, [b=None])', '(a=1, [b=None])'), + ('(a=1[, b=None])', '(a=1, [b=None])'), + ('(a=[], [b=None])', '(a=[], [b=None])'), + ('(a=[][, b=None])', '(a=[], [b=None])'), + ('(a: Foo[Bar]=[][, b=None])', '(a: Foo[Bar]=[], [b=None])'), + ]: + rv = parse(f'func{params}') + assert rv == expect + + # 'def f[Foo[Bar]]()' is not valid Python but people might write it + # in a reST document to convene the intent of a higher-kinded typed + # variable + for tparams in ['Foo', 'Foo[Bar]']: + for retann in ['', '-> Foo', '-> Foo[Bar]', '-> anything else']: + rv = parse(f'func[{tparams}]{params} {retann}'.rstrip()) + assert rv == expect @pytest.mark.sphinx('dummy', testroot='domain-py') @@ -1710,6 +1715,10 @@ def test_pep_695_and_pep_696_whitespaces_in_bound(app, tp_list, tptext): doctree = restructuredtext.parse(app, text) assert doctree.astext() == f'\n\nf{tptext}()\n\n' + text = f'.. py:function:: f{tp_list}() -> Annotated[T, Qux[int]()]' + doctree = restructuredtext.parse(app, text) + assert doctree.astext() == f'\n\nf{tptext}() -> Annotated[T, Qux[int]()]\n\n' + @pytest.mark.parametrize( ('tp_list', 'tptext'), @@ -1724,6 +1733,10 @@ def test_pep_695_and_pep_696_whitespaces_in_constraints(app, tp_list, tptext): doctree = restructuredtext.parse(app, text) assert doctree.astext() == f'\n\nf{tptext}()\n\n' + text = f'.. py:function:: f{tp_list}() -> Annotated[T, Qux[int]()]' + doctree = restructuredtext.parse(app, text) + assert doctree.astext() == f'\n\nf{tptext}() -> Annotated[T, Qux[int]()]\n\n' + @pytest.mark.parametrize( ('tp_list', 'tptext'), @@ -1747,3 +1760,7 @@ def test_pep_695_and_pep_696_whitespaces_in_default(app, tp_list, tptext): text = f'.. py:function:: f{tp_list}()' doctree = restructuredtext.parse(app, text) assert doctree.astext() == f'\n\nf{tptext}()\n\n' + + text = f'.. py:function:: f{tp_list}() -> Annotated[T, Qux[int]()]' + doctree = restructuredtext.parse(app, text) + assert doctree.astext() == f'\n\nf{tptext}() -> Annotated[T, Qux[int]()]\n\n' diff --git a/tests/test_extensions/test_ext_autodoc.py b/tests/test_extensions/test_ext_autodoc.py index 9383401ac6c..cf028829a0c 100644 --- a/tests/test_extensions/test_ext_autodoc.py +++ b/tests/test_extensions/test_ext_autodoc.py @@ -177,6 +177,28 @@ def g(a='\n'): assert formatsig('function', 'f', f, 'a, b, c, d', None) == '(a, b, c, d)' assert formatsig('function', 'g', g, None, None) == r"(a='\n')" + if sys.version_info >= (3, 12): + for params, expect in [ + ('(a=1)', '(a=1)'), + ('(a: int=1)', '(a: int = 1)'), # auto whitespace formatting + ('(a:list[T] =[], b=None)', '(a: list[T] = [], b=None)'), # idem + ]: + ns = {} + exec(f'def f[T]{params}: pass', ns) + f = ns['f'] + assert formatsig('function', 'f', f, None, None) == expect + assert formatsig('function', 'f', f, '...', None) == '(...)' + assert formatsig('function', 'f', f, '...', '...') == '(...) -> ...' + + exec(f'def f[T]{params} -> list[T]: return []', ns) + f = ns['f'] + assert formatsig('function', 'f', f, None, None) == f'{expect} -> list[T]' + assert formatsig('function', 'f', f, '...', None) == '(...)' + assert formatsig('function', 'f', f, '...', '...') == '(...) -> ...' + + # TODO: add more test cases for PEP-695 classes as well (but they are less + # likely + # test for classes class D: pass From 8421e749f6e020a5bc635edd13555caa0f69a690 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Tue, 11 Feb 2025 12:43:16 +0100 Subject: [PATCH 2/7] changelog --- CHANGES.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index 991c2848483..d6484f58e3c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -144,6 +144,8 @@ Bugs fixed * #13302, #13319: Use the correct indentation for continuation lines in :rst:dir:`productionlist` directives. Patch by Adam Turner. +* #13328: Fix parsing of PEP 695 functions with return annotations. + Patch by Bénédikt Tran. Initial work by Arash Badie-Modiri. Testing ------- From fe85e0ccb84e87c9f96b5dcb809d9c962dd9e527 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Tue, 11 Feb 2025 12:45:15 +0100 Subject: [PATCH 3/7] lint --- tests/test_domains/test_domain_py.py | 2 +- tests/test_extensions/test_ext_autodoc.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_domains/test_domain_py.py b/tests/test_domains/test_domain_py.py index 070b6b71a91..f3cc11d5e90 100644 --- a/tests/test_domains/test_domain_py.py +++ b/tests/test_domains/test_domain_py.py @@ -70,7 +70,7 @@ def test_function_signatures(): # variable for tparams in ['Foo', 'Foo[Bar]']: for retann in ['', '-> Foo', '-> Foo[Bar]', '-> anything else']: - rv = parse(f'func[{tparams}]{params} {retann}'.rstrip()) + rv = parse(f'func[{tparams}]{params} {retann}'.rstrip()) assert rv == expect diff --git a/tests/test_extensions/test_ext_autodoc.py b/tests/test_extensions/test_ext_autodoc.py index cf028829a0c..f6e47872e60 100644 --- a/tests/test_extensions/test_ext_autodoc.py +++ b/tests/test_extensions/test_ext_autodoc.py @@ -184,13 +184,13 @@ def g(a='\n'): ('(a:list[T] =[], b=None)', '(a: list[T] = [], b=None)'), # idem ]: ns = {} - exec(f'def f[T]{params}: pass', ns) + exec(f'def f[T]{params}: pass', ns) # NoQA: S102 f = ns['f'] assert formatsig('function', 'f', f, None, None) == expect assert formatsig('function', 'f', f, '...', None) == '(...)' assert formatsig('function', 'f', f, '...', '...') == '(...) -> ...' - exec(f'def f[T]{params} -> list[T]: return []', ns) + exec(f'def f[T]{params} -> list[T]: return []', ns) # NoQA: S102 f = ns['f'] assert formatsig('function', 'f', f, None, None) == f'{expect} -> list[T]' assert formatsig('function', 'f', f, '...', None) == '(...)' From 1f92325252477390148bfb08dccdda150fb4645e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Tue, 11 Feb 2025 12:50:59 +0100 Subject: [PATCH 4/7] more coverage --- tests/test_domains/test_domain_py.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_domains/test_domain_py.py b/tests/test_domains/test_domain_py.py index f3cc11d5e90..62072ee80e9 100644 --- a/tests/test_domains/test_domain_py.py +++ b/tests/test_domains/test_domain_py.py @@ -65,12 +65,12 @@ def test_function_signatures(): rv = parse(f'func{params}') assert rv == expect - # 'def f[Foo[Bar]]()' is not valid Python but people might write it - # in a reST document to convene the intent of a higher-kinded typed - # variable - for tparams in ['Foo', 'Foo[Bar]']: + # Note: 'def f[Foo[Bar]]()' is not valid Python but people might write + # it in a reST document to convene the intent of a higher-kinded type + # variable. + for tparams in ['', '[Foo]', '[Foo[Bar]]']: for retann in ['', '-> Foo', '-> Foo[Bar]', '-> anything else']: - rv = parse(f'func[{tparams}]{params} {retann}'.rstrip()) + rv = parse(f'func{tparams}{params} {retann}'.rstrip()) assert rv == expect From 36968f7e8b45379590c9cdb92050ce664c1e9a86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Tue, 11 Feb 2025 13:20:46 +0100 Subject: [PATCH 5/7] fixup comment --- tests/test_extensions/test_ext_autodoc.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_extensions/test_ext_autodoc.py b/tests/test_extensions/test_ext_autodoc.py index f6e47872e60..6d26c247387 100644 --- a/tests/test_extensions/test_ext_autodoc.py +++ b/tests/test_extensions/test_ext_autodoc.py @@ -196,8 +196,8 @@ def g(a='\n'): assert formatsig('function', 'f', f, '...', None) == '(...)' assert formatsig('function', 'f', f, '...', '...') == '(...) -> ...' - # TODO: add more test cases for PEP-695 classes as well (but they are less - # likely + # TODO(picnixz): add more test cases for PEP-695 classes as well (though + # complex cases are less likely to appear and are painful to test). # test for classes class D: From 688869786d96030a63f9f8a02dcc91e73efd18fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Tue, 11 Feb 2025 18:51:16 +0100 Subject: [PATCH 6/7] Checking if some side-effects appeared due to `exec()` --- tests/test_extensions/test_ext_autodoc.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_extensions/test_ext_autodoc.py b/tests/test_extensions/test_ext_autodoc.py index 6d26c247387..801a9ae5e4b 100644 --- a/tests/test_extensions/test_ext_autodoc.py +++ b/tests/test_extensions/test_ext_autodoc.py @@ -177,7 +177,8 @@ def g(a='\n'): assert formatsig('function', 'f', f, 'a, b, c, d', None) == '(a, b, c, d)' assert formatsig('function', 'g', g, None, None) == r"(a='\n')" - if sys.version_info >= (3, 12): + import os + if sys.version_info >= (3, 12) and os.name != 'nt': for params, expect in [ ('(a=1)', '(a=1)'), ('(a: int=1)', '(a: int = 1)'), # auto whitespace formatting From 8d8a681b236f3c799fc756f6ff3c2103c59c8652 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Tue, 11 Feb 2025 19:03:39 +0100 Subject: [PATCH 7/7] revert fix attempt (didn't help) --- tests/test_extensions/test_ext_autodoc.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_extensions/test_ext_autodoc.py b/tests/test_extensions/test_ext_autodoc.py index 801a9ae5e4b..6d26c247387 100644 --- a/tests/test_extensions/test_ext_autodoc.py +++ b/tests/test_extensions/test_ext_autodoc.py @@ -177,8 +177,7 @@ def g(a='\n'): assert formatsig('function', 'f', f, 'a, b, c, d', None) == '(a, b, c, d)' assert formatsig('function', 'g', g, None, None) == r"(a='\n')" - import os - if sys.version_info >= (3, 12) and os.name != 'nt': + if sys.version_info >= (3, 12): for params, expect in [ ('(a=1)', '(a=1)'), ('(a: int=1)', '(a: int = 1)'), # auto whitespace formatting