Skip to content

Commit

Permalink
Merge pull request #100 from DanCardin/dc/eval_type_backport
Browse files Browse the repository at this point in the history
fix: Use eval_type_backport to allow new syntax in python 3.8/9.
  • Loading branch information
DanCardin authored Feb 27, 2024
2 parents 97a57d4 + c12622d commit a7ab651
Show file tree
Hide file tree
Showing 41 changed files with 441 additions and 352 deletions.
484 changes: 247 additions & 237 deletions poetry.lock

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "cappa"
version = "0.16.2"
version = "0.16.3"
description = "Declarative CLI argument parser."

repository = "https://github.com/dancardin/cappa"
Expand Down Expand Up @@ -30,6 +30,7 @@ python = ">=3.8,<4"
typing-extensions = ">=4.7.1"
typing-inspect = ">=0.9.0"
rich = "*"
eval_type_backport = {version = "*", python = "<3.10"}

docstring-parser = {version = ">=0.15", optional = true}

Expand Down Expand Up @@ -99,7 +100,6 @@ ignore_missing_imports = true
warn_unused_ignores = true
incremental = true
check_untyped_defs = true
enable_incomplete_feature = ["Unpack"]

[tool.pytest.ini_options]
doctest_optionflags = "NORMALIZE_WHITESPACE IGNORE_EXCEPTION_DETAIL ELLIPSIS"
Expand Down
2 changes: 1 addition & 1 deletion src/cappa/arg.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ class Arg(typing.Generic[T]):

field_name: str | MISSING = missing

annotations: list[typing.Type] = dataclasses.field(default_factory=list)
annotations: list[type] = dataclasses.field(default_factory=list)

@classmethod
def collect(
Expand Down
8 changes: 3 additions & 5 deletions src/cappa/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,12 @@
from collections.abc import Callable
from types import ModuleType

from typing_extensions import get_type_hints

from cappa import class_inspect
from cappa.arg import Arg, ArgAction
from cappa.env import Env
from cappa.output import Exit, Output, prompt_types
from cappa.subcommand import Subcommand
from cappa.typing import missing
from cappa.typing import get_type_hints, missing

try:
import docstring_parser as _docstring_parser
Expand Down Expand Up @@ -57,7 +55,7 @@ class Command(typing.Generic[T]):
and the referenced function invoked.
"""

cmd_cls: typing.Type[T]
cmd_cls: type[T]
arguments: list[Arg | Subcommand] = dataclasses.field(default_factory=list)
name: str | None = None
help: str | None = None
Expand All @@ -67,7 +65,7 @@ class Command(typing.Generic[T]):
_collected: bool = False

@classmethod
def get(cls, obj: typing.Type[T] | Command[T]) -> Command[T]:
def get(cls, obj: type[T] | Command[T]) -> Command[T]:
if isinstance(obj, cls):
return obj

Expand Down
4 changes: 2 additions & 2 deletions src/cappa/ext/docutils.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ def render_to_docutils(command: cappa.Command, document):
]
if command_subcommands:
for subcmd in command_subcommands:
for option in subcmd.options.values():
section += render_to_docutils(option, document)
for o in subcmd.options.values():
section += render_to_docutils(o, document)

return [section]
4 changes: 1 addition & 3 deletions src/cappa/invoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,10 @@
from collections.abc import Callable
from dataclasses import dataclass, field

from typing_extensions import get_type_hints

from cappa.command import Command, HasCommand
from cappa.output import Exit, Output
from cappa.subcommand import Subcommand
from cappa.typing import find_type_annotation
from cappa.typing import find_type_annotation, get_type_hints

C = typing.TypeVar("C", bound=HasCommand)

Expand Down
126 changes: 117 additions & 9 deletions src/cappa/typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from typing_inspect import is_literal_type

try:
from typing_extensions import Doc # type: ignore
from typing_extensions import Doc

doc_type: type | None = Doc
except ImportError: # pragma: no cover
Expand All @@ -32,21 +32,19 @@
@dataclass
class ObjectAnnotation(typing.Generic[T]):
obj: T | None
annotation: typing.Type
annotation: type
doc: str | None = None
other_annotations: list[typing.Type] = field(default_factory=list)
other_annotations: list[type] = field(default_factory=list)


def find_type_annotation(
type_hint: typing.Type, kind: typing.Type[T]
) -> ObjectAnnotation[T]:
def find_type_annotation(type_hint: type, kind: type[T]) -> ObjectAnnotation[T]:
instance = None
doc = None

other_annotations = []
if get_origin(type_hint) is Annotated:
annotations = type_hint.__metadata__
type_hint = type_hint.__origin__
annotations = type_hint.__metadata__ # type: ignore
type_hint = type_hint.__origin__ # type: ignore

for annotation in annotations:
is_instance = isinstance(annotation, kind)
Expand Down Expand Up @@ -109,7 +107,7 @@ def is_subclass(typ, superclass):


def get_type_hints(obj, include_extras=False):
result = typing_extensions.get_type_hints(obj, include_extras=include_extras)
result = _get_type_hints(obj, include_extras=include_extras)
if sys.version_info < (3, 11): # pragma: no cover
result = fix_annotated_optional_type_hints(result)

Expand Down Expand Up @@ -144,3 +142,113 @@ def is_of_type(annotation, types):
if is_subclass(arg_annotation, types):
return True
return False


if sys.version_info >= (3, 10):
_get_type_hints = typing.get_type_hints

else:
from eval_type_backport import eval_type_backport

@typing.no_type_check
def _get_type_hints(
obj: typing.Any,
globalns: dict[str, typing.Any] | None = None,
localns: dict[str, typing.Any] | None = None,
include_extras: bool = False,
) -> dict[str, typing.Any]: # pragma: no cover
"""Backport from python 3.10.8, with exceptions.
* Use `_forward_ref` instead of `typing.ForwardRef` to handle the `is_class` argument.
* `eval_type_backport` instead of `eval_type`, to backport syntax changes in Python 3.10.
https://github.com/python/cpython/blob/aaaf5174241496afca7ce4d4584570190ff972fe/Lib/typing.py#L1773-L1875
"""
if getattr(obj, "__no_type_check__", None):
return {}
# Classes require a special treatment.
if isinstance(obj, type):
hints = {}
for base in reversed(obj.__mro__):
if globalns is None:
base_globals = getattr(
sys.modules.get(base.__module__, None), "__dict__", {}
)
else:
base_globals = globalns
ann = base.__dict__.get("__annotations__", {})
if isinstance(ann, types.GetSetDescriptorType):
ann = {}
base_locals = dict(vars(base)) if localns is None else localns
if localns is None and globalns is None:
# This is surprising, but required. Before Python 3.10,
# get_type_hints only evaluated the globalns of
# a class. To maintain backwards compatibility, we reverse
# the globalns and localns order so that eval() looks into
# *base_globals* first rather than *base_locals*.
# This only affects ForwardRefs.
base_globals, base_locals = base_locals, base_globals
for name, value in ann.items():
if value is None:
value = type(None)
if isinstance(value, str):
value = _forward_ref(value, is_argument=False, is_class=True)

value = eval_type_backport(value, base_globals, base_locals)
hints[name] = value
if not include_extras and hasattr(typing, "_strip_annotations"):
return {k: typing._strip_annotations(t) for k, t in hints.items()}
return hints

if globalns is None:
if isinstance(obj, types.ModuleType):
globalns = obj.__dict__
else:
nsobj = obj
# Find globalns for the unwrapped object.
while hasattr(nsobj, "__wrapped__"):
nsobj = nsobj.__wrapped__
globalns = getattr(nsobj, "__globals__", {})
if localns is None:
localns = globalns
elif localns is None:
localns = globalns
hints = getattr(obj, "__annotations__", None)
if hints is None:
# Return empty annotations for something that _could_ have them.
if isinstance(obj, typing._allowed_types):
return {}

raise TypeError(f"{obj!r} is not a module, class, method, " "or function.")
defaults = typing._get_defaults(obj)
hints = dict(hints)
for name, value in hints.items():
if value is None:
value = type(None)
if isinstance(value, str):
# class-level forward refs were handled above, this must be either
# a module-level annotation or a function argument annotation

value = _forward_ref(
value,
is_argument=not isinstance(obj, types.ModuleType),
is_class=False,
)
value = eval_type_backport(value, globalns, localns)
if name in defaults and defaults[name] is None:
value = typing.Optional[value]
hints[name] = value
return (
hints
if include_extras
else {k: typing._strip_annotations(t) for k, t in hints.items()}
)


def _forward_ref(
arg: typing.Any,
is_argument: bool = True,
*,
is_class: bool = False,
) -> typing.ForwardRef:
return typing.ForwardRef(arg, is_argument)
10 changes: 5 additions & 5 deletions tests/arg/test_discriminated_union.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

from dataclasses import dataclass
from typing import Literal, Tuple, Union
from typing import Literal, Union

import pytest

Expand All @@ -13,9 +13,9 @@ def test_valid_tagged_unions(backend):
@dataclass
class ArgTest:
name: Union[
Tuple[Literal["one"], str],
Tuple[Literal["two"], int],
Tuple[
tuple[Literal["one"], str],
tuple[Literal["two"], int],
tuple[
Literal["three"],
float,
],
Expand All @@ -35,7 +35,7 @@ class ArgTest:
def test_disallowed_different_arity_variants(backend):
@dataclass
class ArgTest:
name: Union[Tuple[str, str], Tuple[str, str, str]]
name: Union[tuple[str, str], tuple[str, str, str]]

with pytest.raises(ValueError) as e:
parse(ArgTest, "one", "string", backend=backend)
Expand Down
3 changes: 1 addition & 2 deletions tests/arg/test_explicit_subcommand_types.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
from __future__ import annotations

from dataclasses import dataclass
from typing import Union

import cappa
from typing_extensions import Annotated
from typing_extensions import Annotated, Union

from tests.utils import backends, parse

Expand Down
3 changes: 1 addition & 2 deletions tests/arg/test_file_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,11 @@
import io
from contextlib import contextmanager
from dataclasses import dataclass
from typing import BinaryIO, TextIO
from unittest.mock import mock_open, patch

import cappa
import pytest
from typing_extensions import Annotated
from typing_extensions import Annotated, BinaryIO, TextIO

from tests.utils import backends, parse

Expand Down
2 changes: 1 addition & 1 deletion tests/arg/test_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import cappa
import pytest
from cappa.output import Exit
from typing_extensions import Annotated, Doc # type: ignore
from typing_extensions import Annotated, Doc

from tests.utils import backends, parse

Expand Down
21 changes: 12 additions & 9 deletions tests/arg/test_invalid_annotation_combination.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
from __future__ import annotations

from dataclasses import dataclass
from typing import List, Union

import cappa
import pytest
from typing_extensions import Annotated
from typing_extensions import Annotated, Union

from tests.utils import backends, parse

Expand All @@ -12,7 +13,7 @@
def test_sequence_unioned_with_scalar(backend):
@dataclass
class Args:
foo: Union[List[str], str]
foo: Union[list[str], str]

with pytest.raises(ValueError) as e:
parse(Args, "--help", backend=backend)
Expand All @@ -29,14 +30,15 @@ class Args:
def test_sequence_with_scalar_action(backend):
@dataclass
class Args:
foo: Annotated[List[str], cappa.Arg(action=cappa.ArgAction.set)]
foo: Annotated[list[str], cappa.Arg(action=cappa.ArgAction.set)]

with pytest.raises(ValueError) as e:
parse(Args, "--help", backend=backend)

assert str(e.value) == (
result = str(e.value).replace("typing.List", "list")
assert result == (
"On field 'foo', apparent mismatch of annotated type with `Arg` options. "
"'typing.List[str]' type produces a sequence, whereas `num_args=1`/`action=ArgAction.set` do not. "
"'list[str]' type produces a sequence, whereas `num_args=1`/`action=ArgAction.set` do not. "
"See [documentation](https://cappa.readthedocs.io/en/latest/annotation.html) for more details."
)

Expand All @@ -45,14 +47,15 @@ class Args:
def test_sequence_with_scalar_num_args(backend):
@dataclass
class Args:
foo: Annotated[List[str], cappa.Arg(num_args=1, short=True)]
foo: Annotated[list[str], cappa.Arg(num_args=1, short=True)]

with pytest.raises(ValueError) as e:
parse(Args, "--help", backend=backend)

assert str(e.value) == (
result = str(e.value).replace("typing.List", "list")
assert result == (
"On field 'foo', apparent mismatch of annotated type with `Arg` options. "
"'typing.List[str]' type produces a sequence, whereas `num_args=1`/`action=None` do not. "
"'list[str]' type produces a sequence, whereas `num_args=1`/`action=None` do not. "
"See [documentation](https://cappa.readthedocs.io/en/latest/annotation.html) for more details."
)

Expand Down
Loading

0 comments on commit a7ab651

Please sign in to comment.