Skip to content

Commit

Permalink
Handle typing_extensions.Required/NotRequired properly.
Browse files Browse the repository at this point in the history
These are handled automatically by Python 3.11+ (they are supported
natively), but with earlier versions they appear in type hints and
need to be handled separately. Fixes #5115.
  • Loading branch information
pekkaklarck committed Apr 22, 2024
1 parent ede9ccd commit f85d439
Show file tree
Hide file tree
Showing 4 changed files with 80 additions and 15 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,10 @@ TypedDict
Stringified TypedDict types
Check Test Case ${TESTNAME}

Optional TypedDict keys can be omitted
Optional TypedDict keys can be omitted (total=False)
Check Test Case ${TESTNAME}

Not required TypedDict keys can be omitted (NotRequired/Required)
Check Test Case ${TESTNAME}

Required TypedDict keys cannot be omitted
Expand Down
38 changes: 32 additions & 6 deletions atest/testdata/keywords/type_conversion/AnnotationsWithTyping.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import sys
from typing import (Any, Dict, List, Mapping, MutableMapping, MutableSet,
MutableSequence, Set, Sequence, Tuple, Union)
try:
from typing_extensions import TypedDict
except ImportError:
from typing import TypedDict
MutableSequence, Set, Sequence, Tuple, TypedDict, Union)

if sys.version_info < (3, 9):
from typing_extensions import TypedDict as TypedDictWithRequiredKeys
else:
TypedDictWithRequiredKeys = TypedDict
if sys.version_info < (3, 11):
from typing_extensions import NotRequired, Required
else:
from typing import NotRequired, Required


TypedDict.robot_not_keyword = True


class Point2D(TypedDict):
class Point2D(TypedDictWithRequiredKeys):
x: int
y: int

Expand All @@ -18,6 +24,18 @@ class Point(Point2D, total=False):
z: int


class NotRequiredAnnotation(TypedDict):
x: int
y: 'int | float'
z: NotRequired[int]


class RequiredAnnotation(TypedDict, total=False):
x: Required[int]
y: Required['int | float']
z: int


class Stringified(TypedDict):
a: 'int'
b: 'int | float'
Expand Down Expand Up @@ -100,6 +118,14 @@ def typeddict_with_optional(argument: Point, expected=None):
_validate_type(argument, expected)


def not_required(argument: NotRequiredAnnotation, expected=None):
_validate_type(argument, expected)


def required(argument: RequiredAnnotation, expected=None):
_validate_type(argument, expected)


def stringified_typeddict(argument: Stringified, expected=None):
_validate_type(argument, expected)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,21 +150,30 @@ TypedDict
TypedDict {'x': -10_000, 'y': '2'} {'x': -10000, 'y': 2}
TypedDict ${{{'x': 1, 'y': '2'}}} {'x': 1, 'y': 2}
TypedDict with optional {'x': 1, 'y': 2, 'z': 3} {'x': 1, 'y': 2, 'z': 3}
NotRequired {'x': 1, 'y': 2, 'z': 3} {'x': 1, 'y': 2, 'z': 3}
Required {'x': 1, 'y': 2, 'z': 3} {'x': 1, 'y': 2, 'z': 3}

Stringified TypedDict types
Stringified TypedDict {'a': 1, 'b': 2} {'a': 1, 'b': 2}
Stringified TypedDict {'a': 1, 'b': 2.3} {'a': 1, 'b': 2.3}
Stringified TypedDict {'a': '1', 'b': '2.3'} {'a': 1, 'b': 2.3}

Optional TypedDict keys can be omitted
Optional TypedDict keys can be omitted (total=False)
TypedDict with optional {'x': 0, 'y': '0'} {'x': 0, 'y': 0}
TypedDict with optional ${{{'x': 0, 'y': '0'}}} {'x': 0, 'y': 0}

Not required TypedDict keys can be omitted (NotRequired/Required)
NotRequired {'x': 0, 'y': '0.1'} {'x': 0, 'y': 0.1}
NotRequired ${{{'x': 0, 'y': '0'}}} {'x': 0, 'y': 0}
Required {'x': 0, 'y': '0.1'} {'x': 0, 'y': 0.1}
Required ${{{'x': 0, 'y': '0'}}} {'x': 0, 'y': 0}

Required TypedDict keys cannot be omitted
[Documentation] This test would fail if using Python 3.8 without typing_extensions!
... In that case there's no information about required/optional keys.
[Template] Conversion Should Fail
TypedDict {'x': 123} type=Point2D error=Required item 'y' missing.
Required {'y': 0.1} type=RequiredAnnotation error=Required item 'x' missing.
TypedDict {} type=Point2D error=Required items 'x' and 'y' missing.
TypedDict with optional {} type=Point error=Required items 'x' and 'y' missing.

Expand Down
41 changes: 34 additions & 7 deletions src/robot/running/arguments/typeinfo.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,20 @@
# See the License for the specific language governing permissions and
# limitations under the License.

import sys
from collections.abc import Mapping, Sequence, Set
from datetime import date, datetime, timedelta
from decimal import Decimal
from enum import Enum
from pathlib import Path
from typing import Any, ForwardRef, get_type_hints, Literal, Union
from typing import Any, ForwardRef, get_type_hints, get_origin, Literal, Union
if sys.version_info >= (3, 11):
from typing import NotRequired, Required
else:
try:
from typing_extensions import NotRequired, Required
except ImportError:
NotRequired = Required = object()

from robot.conf import Languages, LanguagesLike
from robot.errors import DataError
Expand Down Expand Up @@ -307,11 +315,30 @@ class TypedDictInfo(TypeInfo):

def __init__(self, name: str, type: type):
super().__init__(name, type)
try:
type_hints = get_type_hints(type)
except Exception:
type_hints = type.__annotations__
self.annotations = {name: TypeInfo.from_type_hint(hint)
for name, hint in type_hints.items()}
type_hints = self._get_type_hints(type)
# __required_keys__ is new in Python 3.9.
self.required = getattr(type, '__required_keys__', frozenset())
if sys.version_info < (3, 11):
self._handle_typing_extensions_required_and_not_required(type_hints)
self.annotations = {name: TypeInfo.from_type_hint(hint)
for name, hint in type_hints.items()}

def _get_type_hints(self, type) -> 'dict[str, Any]':
try:
return get_type_hints(type)
except Exception:
return type.__annotations__

def _handle_typing_extensions_required_and_not_required(self, type_hints):
# NotRequired and Required are handled automatically by Python 3.11 and newer,
# but with older they appear in type hints and need to be handled separately.
required = set(self.required)
for key, hint in type_hints.items():
origin = get_origin(hint)
if origin is Required:
required.add(key)
type_hints[key] = hint.__args__[0]
elif origin is NotRequired:
required.discard(key)
type_hints[key] = hint.__args__[0]
self.required = frozenset(required)

0 comments on commit f85d439

Please sign in to comment.