Skip to content

Commit

Permalink
Add support for PEP 705 and 728 (#723)
Browse files Browse the repository at this point in the history
  • Loading branch information
JelleZijlstra authored Feb 24, 2024
1 parent 9762262 commit cef60b8
Show file tree
Hide file tree
Showing 16 changed files with 727 additions and 168 deletions.
2 changes: 2 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- Add support for the `ReadOnly` type qualifier (PEP 705) and
for the `closed=True` TypedDict argument (PEP 728) (#723)
- Fix some higher-order behavior of `TypeGuard` and `TypeIs` (#719)
- Add support for `TypeIs` from PEP 742 (#718)
- More PEP 695 support: generic classes and functions. Scoping rules
Expand Down
9 changes: 7 additions & 2 deletions pyanalyze/annotated_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

from .extensions import CustomCheck
from .value import (
NO_RETURN_VALUE,
AnnotatedValue,
AnyValue,
CanAssignError,
Expand Down Expand Up @@ -293,7 +294,7 @@ def _min_len_of_value(val: Value) -> Optional[int]:
elif isinstance(val, DictIncompleteValue):
return sum(pair.is_required and not pair.is_many for pair in val.kv_pairs)
elif isinstance(val, TypedDictValue):
return sum(required for required, _ in val.items.values())
return sum(entry.required for entry in val.items.values())
else:
return None

Expand All @@ -314,6 +315,10 @@ def _max_len_of_value(val: Value) -> Optional[int]:
if pair.is_required:
maximum += 1
return maximum
elif isinstance(val, TypedDictValue):
if val.extra_keys is not NO_RETURN_VALUE:
# May have arbitrary number of extra keys
return None
return len(val.items)
else:
# Always None for TypedDicts as TypedDicts may have arbitrary extra keys
return None
82 changes: 67 additions & 15 deletions pyanalyze/annotations.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@

import qcore
import typing_extensions
from typing_extensions import ParamSpec, TypedDict, get_args, get_origin
from typing_extensions import Literal, ParamSpec, TypedDict, get_args, get_origin

from pyanalyze.annotated_types import get_annotated_types_extension

Expand Down Expand Up @@ -102,6 +102,7 @@
SubclassValue,
TypeAlias,
TypeAliasValue,
TypedDictEntry,
TypedDictValue,
TypedValue,
TypeGuardExtension,
Expand Down Expand Up @@ -432,20 +433,32 @@ def _type_from_runtime(
# mypy_extensions.TypedDict
elif is_instance_of_typing_name(val, "_TypedDictMeta"):
required_keys = getattr(val, "__required_keys__", None)
readonly_keys = getattr(val, "__readonly_keys__", None)
# 3.8's typing.TypedDict doesn't have __required_keys__. With
# inheritance, this makes it apparently impossible to figure out which
# keys are required at runtime.
total = getattr(val, "__total__", True)
extra_keys = None
if hasattr(val, "__extra_keys__"):
extra_keys = _type_from_runtime(val.__extra_keys__, ctx)
else:
extra_keys = None
extra_keys = _type_from_runtime(val.__extra_keys__, ctx, is_typeddict=True)
if hasattr(val, "__closed__") and val.__closed__:
extra_keys = _type_from_runtime(val.__extra_items__, ctx, is_typeddict=True)
extra_readonly = False
while isinstance(extra_keys, TypeQualifierValue):
if extra_keys.qualifier == "ReadOnly":
extra_readonly = True
else:
ctx.show_error(f"{extra_keys.qualifier} not allowed on extra_keys")
extra_keys = extra_keys.value
return TypedDictValue(
{
key: _get_typeddict_value(value, ctx, key, required_keys, total)
key: _get_typeddict_value(
value, ctx, key, required_keys, total, readonly_keys
)
for key, value in val.__annotations__.items()
},
extra_keys=extra_keys,
extra_keys_readonly=extra_readonly,
)
elif val is InitVar:
# On 3.6 and 3.7, InitVar[T] just returns InitVar at runtime, so we can't
Expand Down Expand Up @@ -624,15 +637,26 @@ def _get_typeddict_value(
key: str,
required_keys: Optional[Container[str]],
total: bool,
) -> Tuple[bool, Value]:
readonly_keys: Optional[Container[str]],
) -> TypedDictEntry:
val = _type_from_runtime(value, ctx, is_typeddict=True)
if isinstance(val, Pep655Value):
return (val.required, val.value)
if required_keys is None:
required = total
else:
required = key in required_keys
return required, val
if readonly_keys is None:
readonly = False
else:
readonly = key in readonly_keys
while isinstance(val, TypeQualifierValue):
if val.qualifier == "ReadOnly":
readonly = True
elif val.qualifier == "Required":
required = True
elif val.qualifier == "NotRequired":
required = False
val = val.value
return TypedDictEntry(required=required, readonly=readonly, typ=val)


def _eval_forward_ref(
Expand Down Expand Up @@ -797,15 +821,29 @@ def _type_from_subscripted_value(
if len(members) != 1:
ctx.show_error("Required[] requires a single argument")
return AnyValue(AnySource.error)
return Pep655Value(True, _type_from_value(members[0], ctx))
return TypeQualifierValue(
"Required", _type_from_value(members[0], ctx, is_typeddict=True)
)
elif is_typing_name(root, "NotRequired"):
if not is_typeddict:
ctx.show_error("NotRequired[] used in unsupported context")
return AnyValue(AnySource.error)
if len(members) != 1:
ctx.show_error("NotRequired[] requires a single argument")
return AnyValue(AnySource.error)
return Pep655Value(False, _type_from_value(members[0], ctx))
return TypeQualifierValue(
"NotRequired", _type_from_value(members[0], ctx, is_typeddict=True)
)
elif is_typing_name(root, "ReadOnly"):
if not is_typeddict:
ctx.show_error("ReadOnly[] used in unsupported context")
return AnyValue(AnySource.error)
if len(members) != 1:
ctx.show_error("ReadOnly[] requires a single argument")
return AnyValue(AnySource.error)
return TypeQualifierValue(
"ReadOnly", _type_from_value(members[0], ctx, is_typeddict=True)
)
elif is_typing_name(root, "Unpack"):
if not allow_unpack:
ctx.show_error("Unpack[] used in unsupported context")
Expand Down Expand Up @@ -919,8 +957,8 @@ class _SubscriptedValue(Value):


@dataclass
class Pep655Value(Value):
required: bool
class TypeQualifierValue(Value):
qualifier: Literal["Required", "NotRequired", "ReadOnly"]
value: Value


Expand Down Expand Up @@ -1210,15 +1248,29 @@ def _value_of_origin_args(
if len(args) != 1:
ctx.show_error("Required[] requires a single argument")
return AnyValue(AnySource.error)
return Pep655Value(True, _type_from_runtime(args[0], ctx))
return TypeQualifierValue(
"Required", _type_from_runtime(args[0], ctx, is_typeddict=True)
)
elif is_typing_name(origin, "NotRequired"):
if not is_typeddict:
ctx.show_error("NotRequired[] used in unsupported context")
return AnyValue(AnySource.error)
if len(args) != 1:
ctx.show_error("NotRequired[] requires a single argument")
return AnyValue(AnySource.error)
return Pep655Value(False, _type_from_runtime(args[0], ctx))
return TypeQualifierValue(
"NotRequired", _type_from_runtime(args[0], ctx, is_typeddict=True)
)
elif is_typing_name(origin, "ReadOnly"):
if not is_typeddict:
ctx.show_error("ReadOnly[] used in unsupported context")
return AnyValue(AnySource.error)
if len(args) != 1:
ctx.show_error("ReadOnly[] requires a single argument")
return AnyValue(AnySource.error)
return TypeQualifierValue(
"ReadOnly", _type_from_runtime(args[0], ctx, is_typeddict=True)
)
elif is_typing_name(origin, "Unpack"):
if not allow_unpack:
ctx.show_error("Invalid usage of Unpack")
Expand Down
8 changes: 5 additions & 3 deletions pyanalyze/arg_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
KVPair,
NewTypeValue,
SubclassValue,
TypedDictEntry,
TypedDictValue,
TypedValue,
TypeVarValue,
Expand Down Expand Up @@ -218,6 +219,7 @@ class ClassesSafeToInstantiate(PyObjectSequenceOption[type]):
Value,
Extension,
KVPair,
TypedDictEntry,
asynq.ConstFuture,
range,
tuple,
Expand Down Expand Up @@ -732,10 +734,10 @@ def _uncached_get_argspec(
SigParameter(
key,
ParameterKind.KEYWORD_ONLY,
default=None if required else KnownValue(...),
annotation=value,
default=None if entry.required else KnownValue(...),
annotation=entry.typ,
)
for key, (required, value) in td_type.items.items()
for key, entry in td_type.items.items()
]
if td_type.extra_keys is not None:
annotation = GenericValue(
Expand Down
2 changes: 2 additions & 0 deletions pyanalyze/error_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ class ErrorCode(enum.Enum):
disallowed_import = 89
typeis_must_be_subtype = 90
invalid_typeguard = 91
readonly_typeddict = 92


# Allow testing unannotated functions without too much fuss
Expand Down Expand Up @@ -245,6 +246,7 @@ class ErrorCode(enum.Enum):
"TypeIs narrowed type must be a subtype of the input type"
),
ErrorCode.invalid_typeguard: "Invalid use of TypeGuard or TypeIs",
ErrorCode.readonly_typeddict: "TypedDict is read-only",
}


Expand Down
Loading

0 comments on commit cef60b8

Please sign in to comment.