Skip to content

Commit 234625d

Browse files
type evaluation docs (#412)
1 parent 5795e33 commit 234625d

File tree

5 files changed

+79
-36
lines changed

5 files changed

+79
-36
lines changed

docs/changelog.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
- Support the Python 3.10 `match` statement (#376)
5151
- Support the walrus (`:=`) operator (#375)
5252
- Initial support for proposed new "type evaluation"
53-
mechanism (#374, #379, #384)
53+
mechanism (#374, #379, #384, #410)
5454
- Create command-line options for each config option (#373)
5555
- Overhaul treatment of function definitions (#372)
5656
- Support positional-only arguments

docs/type_evaluation.md

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,7 @@ Examples:
339339

340340
@evaluated
341341
def length_or_none(s: str | None = None):
342-
if is_of_type(s, str):
342+
if is_of_type(s, str, exclude_any=False):
343343
return int
344344
else:
345345
return None
@@ -353,9 +353,9 @@ Examples:
353353

354354
@evaluated
355355
def length_or_none2(s: str | None):
356-
if is_of_type(s, str, exclude_any=True):
356+
if is_of_type(s, str):
357357
return int
358-
elif is_of_type(s, None, exclude_any=True):
358+
elif is_of_type(s, None):
359359
return None
360360
else:
361361
return Any
@@ -367,9 +367,9 @@ Examples:
367367

368368
@evaluated
369369
def nested_any(s: Sequence[Any]):
370-
if is_of_type(s, str, exclude_any=True):
370+
if is_of_type(s, str):
371371
show_error("error")
372-
elif is_of_type(s, Sequence[str], exclude_any=True):
372+
elif is_of_type(s, Sequence[str]):
373373
return str
374374
else:
375375
return int
@@ -453,6 +453,31 @@ Examples:
453453
_: Callable[[str], Path | None] = maybe_path # ok
454454
_: Callable[[Literal["x"]], Path] = maybe_path # ok
455455

456+
### Runtime behavior
457+
458+
At runtime, the `@evaluated` decorator returns a dummy function
459+
that throws an error when called, similar to `@overload`. In
460+
order to support dynamic type checkers, it also stores the
461+
original function, keyed by its fully qualified name.
462+
463+
A helper function is provided to retrieve all registered
464+
evaluation functions for a given fully qualified name:
465+
466+
def get_type_evaluations(
467+
fully_qualified_name: str
468+
) -> Sequence[Callable[..., Any]]: ...
469+
470+
For example, if method `B.c` in module `a` has an evaluation function,
471+
`get_type_evaluations("a.B.c")` will retrieve it.
472+
473+
Dummy implementations are provided for the various helper
474+
functions (`is_provided()`, `is_positional()`, `is_keyword()`,
475+
`is_of_type()`, and `show_error()`). These throw an error
476+
if called at runtime.
477+
478+
The `reveal_type()` function has a runtime implementation
479+
that simply returns its argument.
480+
456481
## Discussion
457482

458483
### Interaction with Any
@@ -635,6 +660,17 @@ Thus, type evaluation provides a way to implement checks similar to mypy's
635660
[strict equality](https://mypy.readthedocs.io/en/stable/command_line.html#cmdoption-mypy-strict-equality)
636661
flag directly in stubs.
637662

663+
## Compatibility
664+
665+
The proposal is fully backward compatible.
666+
667+
Type evaluation functions are going to be most frequently useful
668+
in library stubs, where it is often important that multiple type
669+
checkers can parse the stub. In order to unblock usage of the new
670+
feature in stubs, type checker authors could simply ignore the
671+
body of evaluation functions and rely on the signature. This would
672+
still allow other type checkers to fully use the evaluation function.
673+
638674
## Possible extensions
639675

640676
The following features may be useful, but are deferred

pyanalyze/arg_spec.py

Lines changed: 30 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from .options import Options, PyObjectSequenceOption
88
from .analysis_lib import is_positional_only_arg_name
9-
from .extensions import CustomCheck, get_overloads, get_type_evaluation
9+
from .extensions import CustomCheck, get_overloads, get_type_evaluations
1010
from .annotations import Context, RuntimeEvaluator, type_from_runtime
1111
from .config import Config
1212
from .find_unused import used
@@ -517,29 +517,36 @@ def _maybe_make_evaluator_sig(
517517
key = f"{func.__module__}.{func.__qualname__}"
518518
except AttributeError:
519519
return None
520-
evaluation_func = get_type_evaluation(key)
521-
if evaluation_func is None or not hasattr(evaluation_func, "__globals__"):
520+
evaluation_funcs = get_type_evaluations(key)
521+
if not evaluation_funcs:
522522
return None
523-
sig = self._cached_get_argspec(
524-
evaluation_func, impl, is_asynq, in_overload_resolution=True
525-
)
526-
if sig is None:
527-
return None
528-
lines, _ = inspect.getsourcelines(evaluation_func)
529-
code = textwrap.dedent("".join(lines))
530-
body = ast.parse(code)
531-
if not body.body:
532-
return None
533-
evaluator_node = body.body[0]
534-
if not isinstance(evaluator_node, ast.FunctionDef):
535-
return None
536-
evaluator = RuntimeEvaluator(
537-
evaluator_node,
538-
sig.return_value,
539-
evaluation_func.__globals__,
540-
evaluation_func,
541-
)
542-
return replace(sig, evaluator=evaluator)
523+
sigs = []
524+
for evaluation_func in evaluation_funcs:
525+
if evaluation_func is None or not hasattr(evaluation_func, "__globals__"):
526+
return None
527+
sig = self._cached_get_argspec(
528+
evaluation_func, impl, is_asynq, in_overload_resolution=True
529+
)
530+
if not isinstance(sig, Signature):
531+
return None
532+
lines, _ = inspect.getsourcelines(evaluation_func)
533+
code = textwrap.dedent("".join(lines))
534+
body = ast.parse(code)
535+
if not body.body:
536+
return None
537+
evaluator_node = body.body[0]
538+
if not isinstance(evaluator_node, ast.FunctionDef):
539+
return None
540+
evaluator = RuntimeEvaluator(
541+
evaluator_node,
542+
sig.return_value,
543+
evaluation_func.__globals__,
544+
evaluation_func,
545+
)
546+
sigs.append(replace(sig, evaluator=evaluator))
547+
if len(sigs) == 1:
548+
return sigs[0]
549+
return OverloadedSignature(sigs)
543550

544551
def _uncached_get_argspec(
545552
self,

pyanalyze/extensions.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
Dict,
2020
Iterable,
2121
Optional,
22+
Sequence,
2223
Tuple,
2324
List,
2425
Union,
@@ -387,17 +388,17 @@ def f(x: int) -> None:
387388

388389

389390
_overloads: Dict[str, List[Callable[..., Any]]] = defaultdict(list)
390-
_type_evaluations: Dict[str, Optional[Callable[..., Any]]] = {}
391+
_type_evaluations: Dict[str, List[Callable[..., Any]]] = defaultdict(list)
391392

392393

393394
def get_overloads(fully_qualified_name: str) -> List[Callable[..., Any]]:
394395
"""Return all defined runtime overloads for this fully qualified name."""
395396
return _overloads[fully_qualified_name]
396397

397398

398-
def get_type_evaluation(fully_qualified_name: str) -> Optional[Callable[..., Any]]:
399+
def get_type_evaluations(fully_qualified_name: str) -> Sequence[Callable[..., Any]]:
399400
"""Return the type evaluation function for this fully qualified name, or None."""
400-
return _type_evaluations.get(fully_qualified_name)
401+
return _type_evaluations[fully_qualified_name]
401402

402403

403404
if TYPE_CHECKING:
@@ -433,8 +434,7 @@ def patch_typing_overload() -> None:
433434
def evaluated(func: Callable[..., Any]) -> Callable[..., Any]:
434435
"""Marks a type evaluation function."""
435436
key = f"{func.__module__}.{func.__qualname__}"
436-
assert key not in _type_evaluations, f"multiple evaluations for {key}"
437-
_type_evaluations[key] = func
437+
_type_evaluations[key].append(func)
438438
func.__is_type_evaluation__ = True
439439
return func
440440

pyanalyze/stubs/pyanalyze-stubs/extensions.pyi

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22
# from it, because typeshed_client doesn't let
33
# stubs import from non-stub files.
44

5-
from typing import Any, Callable, Optional, List
5+
from typing import Any, Callable, Optional, List, Sequence
66

77
def reveal_type(value: object) -> None: ...
88
def get_overloads(fully_qualified_name: str) -> List[Callable[..., Any]]: ...
9-
def get_type_evaluation(fully_qualified_name: str) -> Optional[Callable[..., Any]]: ...
9+
def get_type_evaluation(fully_qualified_name: str) -> Sequence[Callable[..., Any]]: ...
1010
def overload(func: Callable[..., Any]) -> Callable[..., Any]: ...
1111
def evaluated(func: Callable[..., Any]) -> Callable[..., Any]: ...
1212
def is_provided(arg: Any) -> bool: ...

0 commit comments

Comments
 (0)