diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index f5f0849..3e54aeb 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -15,7 +15,7 @@ jobs: matrix: strategy: matrix: - python-version: ["3.9", "3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12"] name: Pytest on ${{matrix.python-version}} runs-on: ubuntu-latest diff --git a/docs/src/release_notes.rst b/docs/src/release_notes.rst index f66e1db..9883d84 100644 --- a/docs/src/release_notes.rst +++ b/docs/src/release_notes.rst @@ -11,8 +11,9 @@ sphinx-codeautolink adheres to Unreleased ---------- - Declare support for Python 3.12 and 3.13 (:issue:`150`) -- Remove support for Python 3.7 and 3.8 (:issue:`150`) +- Remove support for Python 3.7-3.9 (:issue:`150`, :issue:`157`) - Fix changed whitespace handling in Pygments 2.19 (:issue:`152`) +- Improve support for future and string annotations (:issue:`155`) 0.15.2 (2024-06-03) ------------------- diff --git a/pyproject.toml b/pyproject.toml index 4877d05..eeeb668 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ readme = "readme_pypi.rst" license = {file = "LICENSE"} dynamic = ["version"] -requires-python = ">=3.9" +requires-python = ">=3.10" dependencies = [ "sphinx>=3.2.0", "beautifulsoup4>=4.8.1", @@ -26,7 +26,6 @@ classifiers = [ "Framework :: Sphinx :: Extension", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", diff --git a/src/sphinx_codeautolink/extension/block.py b/src/sphinx_codeautolink/extension/block.py index 1e05ed9..ce6ec48 100644 --- a/src/sphinx_codeautolink/extension/block.py +++ b/src/sphinx_codeautolink/extension/block.py @@ -3,10 +3,10 @@ from __future__ import annotations import re +from collections.abc import Callable from copy import copy from dataclasses import dataclass from pathlib import Path -from typing import Callable from bs4 import BeautifulSoup from docutils import nodes @@ -263,7 +263,7 @@ def _format_source_for_error( guides[ix] = "block source:" pad = max(len(i) + 1 for i in guides) guides = [g.ljust(pad) for g in guides] - return "\n".join([g + s for g, s in zip(guides, lines)]) + return "\n".join([g + s for g, s in zip(guides, lines, strict=True)]) def _parsing_error_msg(self, error: Exception, language: str, source: str) -> str: return "\n".join( diff --git a/src/sphinx_codeautolink/extension/directive.py b/src/sphinx_codeautolink/extension/directive.py index b688610..de39f77 100644 --- a/src/sphinx_codeautolink/extension/directive.py +++ b/src/sphinx_codeautolink/extension/directive.py @@ -136,5 +136,5 @@ def unknown_visit(self, node) -> None: if isinstance(node, DeferredExamples): # Remove surrounding paragraph too node.parent.parent.remove(node.parent) - if isinstance(node, (ConcatMarker, PrefaceMarker, SkipMarker)): + if isinstance(node, ConcatMarker | PrefaceMarker | SkipMarker): node.parent.remove(node) diff --git a/src/sphinx_codeautolink/extension/resolve.py b/src/sphinx_codeautolink/extension/resolve.py index 17168e0..eac3fad 100644 --- a/src/sphinx_codeautolink/extension/resolve.py +++ b/src/sphinx_codeautolink/extension/resolve.py @@ -2,12 +2,14 @@ from __future__ import annotations +from collections.abc import Callable from contextlib import suppress from dataclasses import dataclass from functools import cache from importlib import import_module from inspect import isclass, isroutine -from typing import Any, Callable, Union +from types import UnionType +from typing import Any, Union, get_type_hints from sphinx_codeautolink.parse import Name, NameBreak @@ -116,34 +118,27 @@ def call_value(cursor: Cursor) -> None: def get_return_annotation(func: Callable) -> type | None: """Determine the target of a function return type hint.""" - annotations = getattr(func, "__annotations__", {}) - ret_annotation = annotations.get("return", None) + annotation = get_type_hints(func).get("return") # Inner type from typing.Optional or Union[None, T] - origin = getattr(ret_annotation, "__origin__", None) - args = getattr(ret_annotation, "__args__", None) - if origin is Union and len(args) == 2: # noqa: PLR2004 + origin = getattr(annotation, "__origin__", None) + args = getattr(annotation, "__args__", None) + if (origin is Union or isinstance(annotation, UnionType)) and len(args) == 2: # noqa: PLR2004 nonetype = type(None) if args[0] is nonetype: - ret_annotation = args[1] + annotation = args[1] elif args[1] is nonetype: - ret_annotation = args[0] - - # Try to resolve a string annotation in the module scope - if isinstance(ret_annotation, str): - location = fully_qualified_name(func) - mod, _ = closest_module(tuple(location.split("."))) - ret_annotation = getattr(mod, ret_annotation, ret_annotation) + annotation = args[0] if ( - not ret_annotation - or not isinstance(ret_annotation, type) - or hasattr(ret_annotation, "__origin__") + not annotation + or not isinstance(annotation, type) + or hasattr(annotation, "__origin__") ): msg = f"Unable to follow return annotation of {get_name_for_debugging(func)}." raise CouldNotResolve(msg) - return ret_annotation + return annotation def fully_qualified_name(thing: type | Callable) -> str: diff --git a/src/sphinx_codeautolink/parse.py b/src/sphinx_codeautolink/parse.py index 7f972f0..a0a6df8 100644 --- a/src/sphinx_codeautolink/parse.py +++ b/src/sphinx_codeautolink/parse.py @@ -432,7 +432,7 @@ def visit_Import(self, node: ast.Import | ast.ImportFrom, prefix: str = "") -> N if prefix: self.save_access(Access(LinkContext.import_from, [], prefix_components)) - for import_name, alias in zip(import_names, aliases): + for import_name, alias in zip(import_names, aliases, strict=True): if not import_star: components = [ Component(n, *linenos(node), "load") for n in import_name.split(".") @@ -561,7 +561,7 @@ def visit_MatchClass(self, node: ast.AST) -> None: accesses.append(access) assigns = [] - for attr, pattern in zip(node.kwd_attrs, node.kwd_patterns): + for attr, pattern in zip(node.kwd_attrs, node.kwd_patterns, strict=True): target = self.visit(pattern) attr_comps = [ Component(NameBreak.call, *linenos(node), "load"), diff --git a/tests/extension/__init__.py b/tests/extension/__init__.py index 9092d31..bf3613c 100644 --- a/tests/extension/__init__.py +++ b/tests/extension/__init__.py @@ -30,11 +30,7 @@ any_whitespace = re.compile(r"\s*") ref_tests = [(p.name, p) for p in Path(__file__).with_name("ref").glob("*.txt")] -ref_xfails = { - "ref_fluent_attrs.txt": sys.version_info < (3, 8), - "ref_fluent_call.txt": sys.version_info < (3, 8), - "ref_import_from_complex.txt": sys.version_info < (3, 8), -} +ref_xfails = {} def assert_links(file: Path, links: list): @@ -44,7 +40,7 @@ def assert_links(file: Path, links: list): strings = [any_whitespace.sub("", "".join(b.strings)) for b in blocks] assert len(strings) == len(links) - for s, link in zip(strings, links): + for s, link in zip(strings, links, strict=False): assert s == link @@ -119,7 +115,7 @@ def test_tables(file: Path, tmp_path: Path): strings = [any_whitespace.sub("", "".join(b.strings)) for b in blocks] assert len(strings) == len(links) - for s, link in zip(strings, links): + for s, link in zip(strings, links, strict=False): assert s == link diff --git a/tests/extension/ref/ref_optional.txt b/tests/extension/ref/ref_optional.txt index f799685..9788984 100644 --- a/tests/extension/ref/ref_optional.txt +++ b/tests/extension/ref/ref_optional.txt @@ -1,6 +1,8 @@ test_project test_project.optional attr +test_project.optional_manual +attr # split # split Test project @@ -10,5 +12,6 @@ Test project import test_project test_project.optional().attr + test_project.optional_manual().attr .. automodule:: test_project diff --git a/tests/extension/ref/ref_optional_future.txt b/tests/extension/ref/ref_optional_future.txt new file mode 100644 index 0000000..c26f3d8 --- /dev/null +++ b/tests/extension/ref/ref_optional_future.txt @@ -0,0 +1,17 @@ +future_project +future_project.optional +attr +future_project.optional_manual +attr +# split +# split +Test project +============ + +.. code:: python + + import future_project + future_project.optional().attr + future_project.optional_manual().attr + +.. automodule:: future_project diff --git a/tests/extension/ref/ref_optional_manual.txt b/tests/extension/ref/ref_optional_manual.txt deleted file mode 100644 index f799685..0000000 --- a/tests/extension/ref/ref_optional_manual.txt +++ /dev/null @@ -1,14 +0,0 @@ -test_project -test_project.optional -attr -# split -# split -Test project -============ - -.. code:: python - - import test_project - test_project.optional().attr - -.. automodule:: test_project diff --git a/tests/extension/src/future_project.py b/tests/extension/src/future_project.py new file mode 100644 index 0000000..6236a07 --- /dev/null +++ b/tests/extension/src/future_project.py @@ -0,0 +1,18 @@ +# noqa: INP001 +from __future__ import annotations + +from typing import Optional + + +class Foo: + """Foo test class.""" + + attr: str = "test" + + +def optional() -> Optional[Foo]: # noqa: UP007 + """Return optional type.""" + + +def optional_manual() -> None | Foo: + """Return manually constructed optional type.""" diff --git a/tests/extension/src/test_project/__init__.py b/tests/extension/src/test_project/__init__.py index 9dc558a..b219257 100644 --- a/tests/extension/src/test_project/__init__.py +++ b/tests/extension/src/test_project/__init__.py @@ -1,7 +1,5 @@ """Docstring.""" -from typing import Optional, Union - from .sub import SubBar, subfoo # noqa: F401 @@ -31,15 +29,15 @@ def bar() -> Foo: """Bar test function.""" -def optional() -> Optional[Foo]: +def optional() -> Foo | None: """Return optional type.""" -def optional_manual() -> Union[None, Foo]: +def optional_manual() -> None | Foo: """Return manually constructed optional type.""" -def optional_counter() -> Union[Foo, Baz]: +def optional_counter() -> Foo | Baz: """Failing case for incorrect optional type handling.""" diff --git a/tests/parse/_util.py b/tests/parse/_util.py index cda7fb8..bff875a 100644 --- a/tests/parse/_util.py +++ b/tests/parse/_util.py @@ -26,7 +26,7 @@ def wrapper(self): print(f"components={components}, code_str={code_str}") print("\nParsed names:") [print(n) for n in names] - for n, e in zip(names, expected): + for n, e in zip(names, expected, strict=True): s = ".".join(c for c in n.import_components) assert s == e[0], f"Wrong import! Expected\n{e}\ngot\n{n}" assert n.code_str == e[1], f"Wrong code str! Expected\n{e}\ngot\n{n}"