Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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