From bc71226c3bb8b6033902cbd1b4cdf8ae47d5b959 Mon Sep 17 00:00:00 2001 From: Ashley Whetter Date: Mon, 1 Apr 2024 22:23:52 -0700 Subject: [PATCH] Render PEP-695 type aliases as TypeAlias assignments Partially addresses #414 --- autoapi/_astroid_utils.py | 57 ++++++++++++++++---------- autoapi/_parser.py | 31 ++++++++++++++ autoapi/templates/python/data.rst | 10 ++--- docs/changes/414.feature | 3 ++ tests/python/pep695/conf.py | 20 +++++++++ tests/python/pep695/example/example.py | 4 ++ tests/python/pep695/index.rst | 26 ++++++++++++ tests/python/test_parser.py | 8 ++-- tests/python/test_pyintegration.py | 28 +++++++++++++ tests/test_astroid_utils.py | 11 ++++- tox.ini | 2 +- 11 files changed, 164 insertions(+), 36 deletions(-) create mode 100644 docs/changes/414.feature create mode 100644 tests/python/pep695/conf.py create mode 100644 tests/python/pep695/example/example.py create mode 100644 tests/python/pep695/index.rst diff --git a/autoapi/_astroid_utils.py b/autoapi/_astroid_utils.py index d15b6fb..77d7105 100644 --- a/autoapi/_astroid_utils.py +++ b/autoapi/_astroid_utils.py @@ -116,27 +116,39 @@ def get_full_basenames(node): yield _resolve_annotation(base) -def _get_const_values(node): - value = None - - if isinstance(node, (astroid.nodes.List, astroid.nodes.Tuple)): - new_value = [] - for element in node.elts: - if isinstance(element, astroid.nodes.Const): - new_value.append(element.value) - elif isinstance(element, (astroid.nodes.List, astroid.nodes.Tuple)): - new_value.append(_get_const_values(element)) - else: - break - else: - value = new_value +def _get_const_value(node): + if isinstance(node, astroid.nodes.Const): + if isinstance(node.value, str) and "\n" in node.value: + return '"""{0}"""'.format(node.value) + + class NotConstException(Exception): + pass + + def _inner(node): + if isinstance(node, (astroid.nodes.List, astroid.nodes.Tuple)): + new_value = [] + for element in node.elts: + new_value.append(_inner(element)) + + if isinstance(node, astroid.nodes.Tuple): + return tuple(new_value) - if isinstance(node, astroid.nodes.Tuple): - value = tuple(new_value) - elif isinstance(node, astroid.nodes.Const): - value = node.value + return new_value + elif isinstance(node, astroid.nodes.Const): + # Don't allow multi-line strings inside a data structure. + if isinstance(node.value, str) and "\n" in node.value: + raise NotConstException() + + return node.value + + raise NotConstException() + + try: + result = _inner(node) + except NotConstException: + return None - return value + return repr(result) def get_assign_value(node): @@ -149,8 +161,9 @@ def get_assign_value(node): to get the assignment value from. Returns: - tuple(str, object or None) or None: The name that is assigned - to, and the value assigned to the name (if it can be converted). + tuple(str, str or None) or None: The name that is assigned + to, and the string representation of the value assigned to the name + (if it can be converted). """ try: targets = node.targets @@ -165,7 +178,7 @@ def get_assign_value(node): name = target.attrname else: return None - return (name, _get_const_values(node.value)) + return (name, _get_const_value(node.value)) return None diff --git a/autoapi/_parser.py b/autoapi/_parser.py index 3c347e0..2de9b71 100644 --- a/autoapi/_parser.py +++ b/autoapi/_parser.py @@ -91,6 +91,8 @@ class Parser: value = assign_value[1] annotation = _astroid_utils.get_assign_annotation(node) + if annotation in ("TypeAlias", "typing.TypeAlias"): + value = node.value.as_string() data = { "type": type_, @@ -274,6 +276,35 @@ class Parser: return data + def parse_typealias(self, node): + doc = "" + doc_node = node.next_sibling() + if isinstance(doc_node, astroid.nodes.Expr) and isinstance( + doc_node.value, astroid.nodes.Const + ): + doc = doc_node.value.value + + if isinstance(node.name, astroid.nodes.AssignName): + name = node.name.name + elif isinstance(node.name, astroid.nodes.AssignAttr): + name = node.name.attrname + else: + return [] + + data = { + "type": "data", + "name": name, + "qual_name": self._get_qual_name(name), + "full_name": self._get_full_name(name), + "doc": _prepare_docstring(doc), + "value": node.value.as_string(), + "from_line_no": node.fromlineno, + "to_line_no": node.tolineno, + "annotation": "TypeAlias", + } + + return [data] + def parse(self, node): data = {} diff --git a/autoapi/templates/python/data.rst b/autoapi/templates/python/data.rst index 845a2ae..9466ffb 100644 --- a/autoapi/templates/python/data.rst +++ b/autoapi/templates/python/data.rst @@ -11,7 +11,7 @@ {% endif %} {% if obj.value is not none %} - {% if obj.value is string and obj.value.splitlines()|count > 1 %} + {% if obj.value.splitlines()|count > 1 %} :value: Multiline-String .. raw:: html @@ -20,18 +20,14 @@ .. code-block:: python - """{{ obj.value|indent(width=6,blank=true) }}""" + {{ obj.value|indent(width=6,blank=true) }} .. raw:: html {% else %} - {% if obj.value is string %} - :value: {{ "%r" % obj.value|string|truncate(100) }} - {% else %} - :value: {{ obj.value|string|truncate(100) }} - {% endif %} + :value: {{ obj.value|truncate(100) }} {% endif %} {% endif %} diff --git a/docs/changes/414.feature b/docs/changes/414.feature new file mode 100644 index 0000000..062c83e --- /dev/null +++ b/docs/changes/414.feature @@ -0,0 +1,3 @@ +Render PEP-695 type aliases as TypeAlias assignments. + +Values are also always rendered for TypeAlises and PEP-695 type aliases. diff --git a/tests/python/pep695/conf.py b/tests/python/pep695/conf.py new file mode 100644 index 0000000..c06ea0f --- /dev/null +++ b/tests/python/pep695/conf.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- + +templates_path = ["_templates"] +source_suffix = ".rst" +master_doc = "index" +project = "pyexample" +copyright = "2015, readthedocs" +author = "readthedocs" +version = "0.1" +release = "0.1" +language = "en" +exclude_patterns = ["_build"] +pygments_style = "sphinx" +todo_include_todos = False +html_theme = "alabaster" +htmlhelp_basename = "pyexampledoc" +extensions = ["sphinx.ext.intersphinx", "sphinx.ext.autodoc", "autoapi.extension"] +intersphinx_mapping = {"python": ("https://docs.python.org/3.10", None)} +autoapi_dirs = ["example"] +autoapi_file_pattern = "*.py" diff --git a/tests/python/pep695/example/example.py b/tests/python/pep695/example/example.py new file mode 100644 index 0000000..7d2c648 --- /dev/null +++ b/tests/python/pep695/example/example.py @@ -0,0 +1,4 @@ +from typing import TypeAlias + +MyTypeAliasA: TypeAlias = tuple[str, int] +type MyTypeAliasB = tuple[str, int] diff --git a/tests/python/pep695/index.rst b/tests/python/pep695/index.rst new file mode 100644 index 0000000..e3395cc --- /dev/null +++ b/tests/python/pep695/index.rst @@ -0,0 +1,26 @@ +.. pyexample documentation master file, created by + sphinx-quickstart on Fri May 29 13:34:37 2015. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to pyexample's documentation! +===================================== + +.. toctree:: + + autoapi/index + +Contents: + +.. toctree:: + :maxdepth: 2 + + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` + diff --git a/tests/python/test_parser.py b/tests/python/test_parser.py index a7aa523..2d21de3 100644 --- a/tests/python/test_parser.py +++ b/tests/python/test_parser.py @@ -25,7 +25,7 @@ class TestPythonParser: """ data = self.parse(source)[0] assert data["name"] == "__all__" - assert data["value"] == ["Foo", 5.0] + assert data["value"] == "['Foo', 5.0]" def test_parses_all_multiline(self): source = """ @@ -35,7 +35,7 @@ class TestPythonParser: ] """ data = self.parse(source)[0] - assert data["value"] == ["foo", "bar"] + assert data["value"] == "['foo', 'bar']" def test_parses_name(self): source = "foo.bar" @@ -43,7 +43,7 @@ class TestPythonParser: def test_parses_list(self): name = "__all__" - value = [1, 2, 3, 4] + value = "[1, 2, 3, 4]" source = "{} = {}".format(name, value) data = self.parse(source)[0] assert data["name"] == name @@ -51,7 +51,7 @@ class TestPythonParser: def test_parses_nested_list(self): name = "__all__" - value = [[1, 2], [3, 4]] + value = "[[1, 2], [3, 4]]" source = "{} = {}".format(name, value) data = self.parse(source)[0] assert data["name"] == name diff --git a/tests/python/test_pyintegration.py b/tests/python/test_pyintegration.py index 794ce48..2e00220 100644 --- a/tests/python/test_pyintegration.py +++ b/tests/python/test_pyintegration.py @@ -587,6 +587,34 @@ class TestPipeUnionModule: assert links[1].text == "None" +@pytest.mark.skipif( + sys.version_info < (3, 12), reason="PEP-695 support requires Python >=3.12" +) +class TestPEP695: + @pytest.fixture(autouse=True, scope="class") + def built(self, builder): + builder("pep695", warningiserror=True) + + def test_integration(self, parse): + example_file = parse("_build/html/autoapi/example/index.html") + + alias = example_file.find(id="example.MyTypeAliasA") + properties = alias.find_all(class_="property") + assert len(properties) == 2 + annotation = properties[0].text + assert annotation == ": TypeAlias" + value = properties[1].text + assert value == " = tuple[str, int]" + + alias = example_file.find(id="example.MyTypeAliasB") + properties = alias.find_all(class_="property") + assert len(properties) == 2 + annotation = properties[0].text + assert annotation == ": TypeAlias" + value = properties[1].text + assert value == " = tuple[str, int]" + + def test_napoleon_integration_loaded(builder, parse): confoverrides = { "exclude_patterns": ["manualapi.rst"], diff --git a/tests/test_astroid_utils.py b/tests/test_astroid_utils.py index 7db157b..46b6eac 100644 --- a/tests/test_astroid_utils.py +++ b/tests/test_astroid_utils.py @@ -92,10 +92,17 @@ class TestAstroidUtils: @pytest.mark.parametrize( ("source", "expected"), [ - ('a = "a"', ("a", "a")), - ("a = 1", ("a", 1)), + ('a = "a"', ("a", "'a'")), + ("a = 1", ("a", "1")), ("a, b, c = (1, 2, 3)", None), ("a = b = 1", None), + ("a = [1, 2, [3, 4]]", ("a", "[1, 2, [3, 4]]")), + ("a = [1, 2, variable[subscript]]", ("a", None)), + ('a = """multiline\nstring"""', ("a", '"""multiline\nstring"""')), + ('a = ["""multiline\nstring"""]', ("a", None)), + ("a = (1, 2, 3)", ("a", "(1, 2, 3)")), + ("a = (1, 'two', 3)", ("a", "(1, 'two', 3)")), + ("a = None", ("a", "None")), ], ) def test_can_get_assign_values(self, source, expected): diff --git a/tox.ini b/tox.ini index fc1f383..40b1daa 100644 --- a/tox.ini +++ b/tox.ini @@ -25,7 +25,7 @@ commands = pytest {posargs} [testenv:formatting] -basepython = python3 +basepython = python312 skip_install = true deps = black