Skip to content

Commit

Permalink
Support future and string annotations (#155)
Browse files Browse the repository at this point in the history
* Support future and string annotations (#155)
* Remove support for Python 3.9
  • Loading branch information
felix-hilden authored Jan 11, 2025
1 parent e3733d3 commit 9dd69fa
Show file tree
Hide file tree
Showing 14 changed files with 67 additions and 54 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion docs/src/release_notes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)
-------------------
Expand Down
3 changes: 1 addition & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions src/sphinx_codeautolink/extension/block.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion src/sphinx_codeautolink/extension/directive.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
31 changes: 13 additions & 18 deletions src/sphinx_codeautolink/extension/resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions src/sphinx_codeautolink/parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(".")
Expand Down Expand Up @@ -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"),
Expand Down
10 changes: 3 additions & 7 deletions tests/extension/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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


Expand Down Expand Up @@ -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


Expand Down
3 changes: 3 additions & 0 deletions tests/extension/ref/ref_optional.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
test_project
test_project.optional
attr
test_project.optional_manual
attr
# split
# split
Test project
Expand All @@ -10,5 +12,6 @@ Test project

import test_project
test_project.optional().attr
test_project.optional_manual().attr

.. automodule:: test_project
17 changes: 17 additions & 0 deletions tests/extension/ref/ref_optional_future.txt
Original file line number Diff line number Diff line change
@@ -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
14 changes: 0 additions & 14 deletions tests/extension/ref/ref_optional_manual.txt

This file was deleted.

18 changes: 18 additions & 0 deletions tests/extension/src/future_project.py
Original file line number Diff line number Diff line change
@@ -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."""
8 changes: 3 additions & 5 deletions tests/extension/src/test_project/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
"""Docstring."""

from typing import Optional, Union

from .sub import SubBar, subfoo # noqa: F401


Expand Down Expand Up @@ -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."""


Expand Down
2 changes: 1 addition & 1 deletion tests/parse/_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down

0 comments on commit 9dd69fa

Please sign in to comment.