Skip to content

Commit be1a707

Browse files
nr/fake hint fixes (#30)
* fix: Fixed an error when evaluating `FakeHint` objects when an argument to a type-hint such as `Annotated` was the constant value `True`, `False`, `None`, `0` or `1`. * Add `FakeProvider.execute() * remove visiting of ast.Constant
1 parent 39e2280 commit be1a707

File tree

7 files changed

+77
-33
lines changed

7 files changed

+77
-33
lines changed

.changelog/_unreleased.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
[[entries]]
2+
id = "e7afde99-2c2d-493a-bb4e-120d56e41a36"
3+
type = "fix"
4+
description = "Fixed an error when evaluating `FakeHint` objects when an argument to a type-hint such as `Annotated` was the constant value `True`, `False`, `None`, `0` or `1`."
5+
author = "@NiklasRosenstein"

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ types-dataclasses = "^0.6.5"
3232
isort = "^5.10.1"
3333
flake8 = "^4.0.1"
3434
black = "^22.3.0"
35+
astor = "^0.8.1"
3536

3637
[tool.poetry.group.docs]
3738
optional = true

src/typeapi/future/astrewrite.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,16 @@
1111
T_AST = t.TypeVar("T_AST", bound=ast.AST)
1212

1313

14-
def rewrite_expr(source: str, lookup_target: str) -> CodeType:
14+
def rewrite_expr_to_ast(source: str, lookup_target: str) -> "ast.Expression | ast.Module":
1515
expr = ast.parse(source, "<expr>", "eval")
1616
expr = DynamicLookupRewriter(lookup_target).visit(expr)
1717
ast.fix_missing_locations(expr)
18+
assert isinstance(expr, (ast.Expression, ast.Module)), type(expr)
19+
return expr
20+
21+
22+
def rewrite_expr(source: str, lookup_target: str) -> CodeType:
23+
expr = rewrite_expr_to_ast(source, lookup_target)
1824
return t.cast(CodeType, compile(expr, "<expr>", "eval")) # type: ignore[redundant-cast] # Redundant in 3.7+
1925

2026

@@ -81,13 +87,6 @@ def visit_Name(self, node: ast.Name) -> ast.AST:
8187
ctx=node.ctx,
8288
)
8389

84-
if hasattr(ast, "Constant"): # Introduced in Python 3.8
85-
86-
def visit_Constant(self, node: ast.Constant) -> ast.AST:
87-
if node.value in (None, True, False):
88-
return self.visit_Name(ast.Name(id=str(node.value), ctx=ast.Load()))
89-
return self.generic_visit(node)
90-
9190
def visit_Assign(self, assign: ast.Assign) -> ast.AST:
9291
if len(assign.targets) == 1 and isinstance(assign.targets[0], ast.Name):
9392
name = assign.targets[0]

src/typeapi/future/astrewrite_test.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import ast
2+
import re
3+
4+
import astor # type: ignore[import]
5+
6+
from typeapi.future.astrewrite import rewrite_expr_to_ast
7+
8+
9+
def to_source(ast: ast.AST) -> str:
10+
# We can't set the line width of the generated code, so we use this to narrow expressions
11+
# down into a single line. We're still left with some space for example when a line break
12+
# occurred after a parentheses, `[\n 0]` will result in `[ 0]`.
13+
return re.sub(r" +", " ", astor.to_source(ast).replace("\n", ""))
14+
15+
16+
def test__rewrite_expr__deals_with_literals() -> None:
17+
assert (
18+
to_source(rewrite_expr_to_ast("Annotated[int | str, 0, '42', Decimal(...)]", "__dict__"))
19+
== "__dict__['Annotated'][__dict__['int'] | __dict__['str'], 0, '42', __dict__[ 'Decimal'](...)]"
20+
)

src/typeapi/future/fake.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import builtins
66
from typing import Any, Optional, Tuple, Union
77

8+
from typeapi.future.astrewrite import rewrite_expr
9+
810
from ..utils import HasGetitem, get_subscriptable_type_hint_from_origin
911

1012

@@ -20,8 +22,10 @@ def __init__(self, origin: Any, args: Optional[Tuple["FakeHint", ...]] = None) -
2022
def __repr__(self) -> str:
2123
return f"FakeHint({self.origin}, args={self.args})"
2224

23-
def __or__(self, other: "FakeHint") -> "FakeHint":
24-
assert isinstance(other, FakeHint)
25+
def __or__(self, other: "FakeHint | None") -> "FakeHint":
26+
if other is None:
27+
other = FakeHint(None)
28+
assert isinstance(other, FakeHint), type(other)
2529
if self.origin == Union:
2630
assert self.args is not None
2731
return FakeHint(Union, self.args + (other,))
@@ -55,6 +59,14 @@ def evaluate(self) -> Any:
5559

5660

5761
class FakeProvider:
62+
"""
63+
This class serves as a lookup target when executing Python typing expressions. It wraps all lookups
64+
returned by *content* in #FakeHint objects (this is achieved in combination with #rewrite_expr()).
65+
This constructs a hierarchy of the operations performed in the expression as #FakeHint objects which
66+
can then be evaluated using #FakeHint.evaluate() to simulate modern Python typing features (such as
67+
advanced union syntax using the `|` operator and built-in type subscripting to generalize templates).
68+
"""
69+
5870
def __init__(self, content: HasGetitem[str, Any]) -> None:
5971
self.content = content
6072

@@ -64,3 +76,20 @@ def __getitem__(self, key: str) -> FakeHint:
6476
except KeyError:
6577
value = vars(builtins)[key]
6678
return FakeHint(get_subscriptable_type_hint_from_origin(value))
79+
80+
def execute(self, expr: str) -> FakeHint:
81+
"""
82+
Executes a type-hint expression and returns the #FakeHint for it which can then be evaluated with
83+
#FakeHint.evaluate() to construct the actual Python `typing` type hint object.
84+
"""
85+
86+
code = rewrite_expr(expr, "__dict__")
87+
result = eval(code, {"__dict__": self})
88+
89+
# We don't wrap all expressions into FakeHint objects via rewrite_expr(), but only names. If the expressions
90+
# was a literal string for example, we need to turn that into a FakeHint.
91+
if not isinstance(result, FakeHint):
92+
result = FakeHint(result)
93+
94+
assert isinstance(result, FakeHint)
95+
return result

src/typeapi/future/fake_test.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
from typing import List, Optional, Union
1+
from typing import Any, List, Optional, Union
22

33
import pytest
44

5-
from typeapi.future.fake import FakeHint
5+
from typeapi.future.astrewrite import rewrite_expr
6+
from typeapi.future.fake import FakeHint, FakeProvider
67

78

89
def test__FakeHint__evaluate() -> None:
@@ -35,6 +36,7 @@ def test__FakeHint__getattr() -> None:
3536

3637
def test__FakeHint__Optional() -> None:
3738
assert FakeHint(Optional)[FakeHint(int)].evaluate() == Optional[int]
39+
assert (FakeHint(None) | FakeHint(int)).evaluate() == Optional[int]
3840

3941

4042
def test__FakeHint__callable() -> None:
@@ -43,3 +45,10 @@ def test__FakeHint__callable() -> None:
4345
with pytest.raises(RuntimeError) as excinfo:
4446
FakeHint(Optional)[FakeHint(int)]("foobar")
4547
assert str(excinfo.value) == "FakeHint(typing.Optional, args=(FakeHint(<class 'int'>, args=None),)) is not callable"
48+
49+
50+
@pytest.mark.parametrize("val", ["FooBar", 0, 42.3, True, False, None])
51+
def test__FakeHint__from_constant(val: Any) -> None:
52+
scope = {"__dict__": FakeProvider({})}
53+
expr = eval(rewrite_expr(repr(val), "__dict__"), scope)
54+
assert expr == val

src/typeapi/typehint.py

Lines changed: 2 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -553,31 +553,12 @@ def parameterize(self, parameter_map: Mapping[object, Any]) -> TypeHint:
553553
)
554554

555555
def evaluate(self, context: "HasGetitem[str, Any] | None" = None) -> TypeHint:
556-
from .future.astrewrite import rewrite_expr
557-
from .future.fake import FakeHint, FakeProvider
556+
from .future.fake import FakeProvider
558557

559558
if context is None:
560559
context = self.get_context()
561560

562-
code = rewrite_expr(self.expr, "__dict__")
563-
scope = {"__dict__": FakeProvider(context)}
564-
hint = eval(code, scope, {})
565-
566-
assert isinstance(hint, FakeHint), (self.expr, FakeHint)
567-
hint = hint.evaluate()
568-
569-
# # Even though eval expects a Mapping, we know for forward references that we'll only
570-
# # need to have __getitem__() as they are pure expressions.
571-
# retyped_context = cast(Mapping[str, Any], FakeProvider(context))
572-
573-
# if IS_PYTHON_AT_LAST_3_6:
574-
# hint = eval(code, scope, {})
575-
# elif IS_PYTHON_AT_LAST_3_8:
576-
# # Mypy doesn't know about the third arg
577-
# hint = self.ref._evaluate(scope, {}) # type: ignore[arg-type]
578-
# else:
579-
# hint = self.ref._evaluate(scope, {}, set()) # type: ignore[arg-type,call-arg]
580-
561+
hint = FakeProvider(context).execute(self.expr).evaluate()
581562
return TypeHint(hint).evaluate(context)
582563

583564
@property

0 commit comments

Comments
 (0)