Skip to content
Merged
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "winter"
version = "29.0.2"
version = "29.1.0"
homepage = "https://github.com/WinterFramework/winter"
description = "Web Framework with focus on python typing, dataclasses and modular design"
authors = ["Alexander Egorov <[email protected]>"]
Expand Down
14 changes: 11 additions & 3 deletions tests/api/api_with_query_parameters.py
Original file line number Diff line number Diff line change
@@ -1,36 +1,44 @@
import datetime
from typing import Any

from typing import Dict
from typing import List
from typing import Optional
from typing import TypeAlias
from uuid import UUID

import winter

ArrayAlias: TypeAlias = list[int]


@winter.route('with-query-parameter')
class APIWithQueryParameters:

@winter.map_query_parameter('string', to='mapped_string')
@winter.route_get('/{?date,boolean,optional_boolean,date_time,array,expanded_array*,string,uid}')
@winter.route_get('/{?date,boolean,optional_boolean,optional_boolean_new_typing_style,date_time,array,array_new_typing_style,array_alias,expanded_array*,string,uid}')
def root(
self,
date: datetime.date,
date_time: datetime.datetime,
boolean: bool,
array: List[int],
array_new_typing_style: list[int],
array_alias: ArrayAlias,
expanded_array: List[str],
mapped_string: str,
uid: UUID,
optional_boolean: Optional[bool] = None,
) -> Dict[str, Any]:
optional_boolean_new_typing_style: bool | None = None,
) -> dict[str, Any]:
return {
'date': date,
'date_time': date_time,
'boolean': boolean,
'optional_boolean': optional_boolean,
'optional_boolean_new_typing_style': optional_boolean_new_typing_style,
'array': array,
'array_new_typing_style': array_new_typing_style,
'array_alias': array_alias,
'expanded_array': expanded_array,
'string': mapped_string,
'uid': str(uid),
Expand Down
6 changes: 6 additions & 0 deletions tests/api/api_with_request_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from typing import Optional

import dataclasses
from typing import TypeAlias

import winter

Expand All @@ -11,16 +12,21 @@ class Status(enum.Enum):
ACTIVE = 'active'
SUPER_ACTIVE = 'super_active'

ItemsTypeAlias: TypeAlias = list[int]


@dataclasses.dataclass
class Data:
id: int
name: str
is_god: bool
optional_status: Optional[Status]
optional_status_new_typing_style: Status | None
status: Status
items: List[int]
items_alias: ItemsTypeAlias
optional_items: Optional[List[int]]
optional_items_new_typing_style: list[int] | None
with_default: int = 5


Expand Down
16 changes: 16 additions & 0 deletions tests/core/json/test_decoder.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ class User:
emails: List[str] = dataclasses.field(default_factory=list)
created_at: Optional[datetime.datetime] = None
name: str = 'test name'
comments: list[str] | None = None


@dataclasses.dataclass(frozen=True)
Expand All @@ -79,6 +80,7 @@ class Profile:
'contact': {
'phones': ['123', '456'],
},
'comments': ['comment1', 'comment2'],
},
User(
Id(1),
Expand All @@ -89,6 +91,7 @@ class Profile:
['[email protected]'],
datetime.datetime(year=2001, month=2, day=3, hour=4, minute=5, second=6, tzinfo=tzutc()),
'name',
['comment1', 'comment2'],
),
),
(
Expand Down Expand Up @@ -127,7 +130,9 @@ def test_decode(data, expected_instance):
@pytest.mark.parametrize(
('data', 'type_', 'expected_instance'), (
(['super'], Set[Status], {Status.SUPER}),
(['super'], set[Status], {Status.SUPER}),
([1], Set, {1}),
([1], set, {1}),
),
)
def test_decode_set(data, type_, expected_instance):
Expand All @@ -150,6 +155,8 @@ def test_decode_sequence(data, type_, expected_instance):
('data', 'type_', 'expected_instance'), (
(None, Optional[Status], None),
('super', Optional[Status], Status.SUPER),
(None, Status | None, None),
('super', Status | None, Status.SUPER),
),
)
def test_decode_optional(data, type_, expected_instance):
Expand Down Expand Up @@ -180,6 +187,7 @@ def test_decode_set_with_errors(data, type_, expected_errors):
(['super'], List[Status], [Status.SUPER]),
(['1'], List[IntStatus], [IntStatus.SUPER]),
([1], List[IntStatus], [IntStatus.SUPER]),
([1], list[IntStatus], [IntStatus.SUPER]),
({1}, List, [1]),
({1}, list, [1]),
),
Expand All @@ -198,6 +206,7 @@ def test_decode_list(data, type_, expected_instance):
),
(1, List[Status], 'Cannot decode "1" to list'),
(None, List[Status], 'Cannot decode "None" to list'),
(None, list[Status], 'Cannot decode "None" to list'),
(None, List[IntStatus], 'Cannot decode "None" to list'),
(['a'], List[IntStatus], 'Value not in allowed values("1", "2"): "a"'),
),
Expand Down Expand Up @@ -465,6 +474,11 @@ class DataclassWithUndefinedType:
a: Union[int, Undefined]


@dataclasses.dataclass
class DataclassWithUndefinedTypeNewTypingStyle:
a: int | Undefined


@dataclasses.dataclass
class DataclassWithUndefinedByDefault:
a: Union[int, Undefined] = Undefined()
Expand All @@ -474,6 +488,8 @@ class DataclassWithUndefinedByDefault:
('data', 'type_', 'expected_result'), (
({}, DataclassWithUndefinedType, DataclassWithUndefinedType(a=Undefined())),
({'a': 123}, DataclassWithUndefinedType, DataclassWithUndefinedType(a=123)),
({}, DataclassWithUndefinedTypeNewTypingStyle, DataclassWithUndefinedTypeNewTypingStyle(a=Undefined())),
({'a': 123}, DataclassWithUndefinedTypeNewTypingStyle, DataclassWithUndefinedTypeNewTypingStyle(a=123)),
({}, DataclassWithUndefinedByDefault, DataclassWithUndefinedByDefault(a=Undefined())),
({'a': 123}, DataclassWithUndefinedByDefault, DataclassWithUndefinedByDefault(a=123)),
),
Expand Down
12 changes: 10 additions & 2 deletions tests/routing/test_query_parameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,27 +183,33 @@ def test_query_parameter(api_client, date, date_time, boolean, optional_boolean,
'date_time': '2019-05-01T22:28:31',
'boolean': boolean == 'true',
'optional_boolean': optional_boolean == 'true' if optional_boolean is not None else None,
'optional_boolean_new_typing_style': optional_boolean == 'true' if optional_boolean is not None else None,
'array': array,
'array_new_typing_style': array,
'array_alias': array,
'expanded_array': list(map(str, array)),
'string': string,
'uid': str(uid),
}
base_uri = URITemplate(
'/with-query-parameter/'
'{?date,date_time,boolean,optional_boolean,array,expanded_array*,string,uid}',
'{?date,date_time,boolean,optional_boolean,optional_boolean_new_typing_style,array,array_new_typing_style,array_alias,expanded_array*,string,uid}',
)
query_params = {
'date': date,
'date_time': date_time,
'boolean': boolean,
'array': ','.join(map(str, array)),
'array_new_typing_style': ','.join(map(str, array)),
'array_alias': ','.join(map(str, array)),
'expanded_array': array,
'string': string,
'uid': uid,
}

if optional_boolean is not None:
query_params['optional_boolean'] = optional_boolean
query_params['optional_boolean_new_typing_style'] = optional_boolean

base_uri = base_uri.expand(**query_params)

Expand All @@ -215,13 +221,15 @@ def test_query_parameter(api_client, date, date_time, boolean, optional_boolean,
def test_invalid_uuid_query_parameter_triggers_400(api_client):
base_uri = URITemplate(
'/with-query-parameter/'
'{?date,date_time,boolean,optional_boolean,array,expanded_array*,string,uid}',
'{?date,date_time,boolean,optional_boolean,array,array_new_typing_style,array_alias,expanded_array*,string,uid}',
)
query_params = {
'date': datetime.datetime.now().date(),
'date_time': datetime.datetime.now(),
'boolean': 'true',
'array': '5',
'array_new_typing_style': '5',
'array_alias': '5',
'expanded_array': ['5'],
'string': '',
'uid': str(uuid4()) + 'a',
Expand Down
21 changes: 16 additions & 5 deletions tests/test_type_utils.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import sys

from collections.abc import Iterable
from types import UnionType
from typing import Dict
from typing import List
from typing import Optional
from typing import Tuple
from typing import Union

import pytest

from winter.core.utils.typing import get_origin_type
from winter.core.utils.typing import get_type_name
from winter.core.utils.typing import is_iterable_type
Expand All @@ -20,6 +20,8 @@
('typing_for_check', 'expected'), [
(Optional[List[int]], True),
(Union[List[int], Tuple], True),
(Union[List | Tuple], True),
(Union[list | tuple], True),
(int, False),
(str, False),
(Optional[List[int]], True),
Expand All @@ -34,6 +36,7 @@ def test_is_iterable_type(typing_for_check, expected):
('typing_for_check', 'expected'), [
(Optional[int], True),
(Union[List, Tuple], True),
(Union[list, tuple], True),
(Tuple, False),
(Union, True),
],
Expand All @@ -46,7 +49,9 @@ def test_is_union(typing_for_check, expected):
('typing_for_check', 'expected'), [
(Optional[int], True),
(Union[int, None], True),
(int | None, True),
(Union[int, list], False),
(int | list, False),
(int, False),
(Union, False),
],
Expand All @@ -60,6 +65,7 @@ def test_is_optional(typing_for_check, expected):
(int, int),
(Union[int], int),
(Union[int, float], Union),
(int | float, UnionType),
],
)
def test_get_origin_type(typing_for_check, expected):
Expand All @@ -71,6 +77,7 @@ def test_get_origin_type(typing_for_check, expected):
(Union[int], int, True),
(List, Iterable, True),
(List[int], Iterable, True),
(list[int], Iterable, True),
],
)
def test_is_origin_type_subclasses(typing_for_check, subclasses_type, expected):
Expand All @@ -82,11 +89,15 @@ def test_is_origin_type_subclasses(typing_for_check, subclasses_type, expected):
@pytest.mark.parametrize(
('type_', 'expected_type_name'), [
(int, 'int'),
(Dict[int, str], 'Dict[int, str]' if sys.version_info >= (3, 7, 0) else 'Dict'),
(Tuple[int], 'Tuple[int]' if sys.version_info >= (3, 7, 0) else 'Tuple'),
(List[int], 'List[int]' if sys.version_info >= (3, 7, 0) else 'List'),
(Dict[int, str], 'Dict[int, str]'),
(Tuple[int], 'Tuple[int]'),
(List[int], 'List[int]'),
(Optional[str], 'Optional[str]'),
(Union[int, str], 'Union[int, str]'),
(dict[int, str], 'dict[int, str]'),
(tuple[int], 'tuple[int]'),
(list[int], 'list[int]'),
(int | str, 'int | str'),
(1, 'int'),
],
)
Expand Down
10 changes: 10 additions & 0 deletions tests/web/test_request_body.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ def test_request_body(api_client):
'is_god': True,
'status': 'active',
'items': [1, 2],
'items_alias': [1, 2],
}
expected_data = {
'id': 1,
Expand All @@ -20,8 +21,11 @@ def test_request_body(api_client):
'is_god': True,
'status': 'active',
'optional_status': None,
'optional_status_new_typing_style': None,
'items': [1, 2],
'items_alias': [1, 2],
'optional_items': None,
'optional_items_new_typing_style': None,
}

# Act
Expand All @@ -38,6 +42,7 @@ def test_request_body_as_list(api_client):
'is_god': True,
'status': 'active',
'items': [1, 2],
'items_alias': [1, 2],
}]
expected_data = [{
'id': 1,
Expand All @@ -46,8 +51,11 @@ def test_request_body_as_list(api_client):
'is_god': True,
'status': 'active',
'optional_status': None,
'optional_status_new_typing_style': None,
'items': [1, 2],
'items_alias': [1, 2],
'optional_items': None,
'optional_items_new_typing_style': None,
}]

# Act
Expand All @@ -64,6 +72,7 @@ def test_request_body_with_errors(api_client):
'status': 'invalid status',
'invalid_key': 'data',
'items': ['invalid integer'],
'items_alias': ['invalid integer'],
}

expected_data = {
Expand All @@ -75,6 +84,7 @@ def test_request_body_with_errors(api_client):
'id': 'Cannot decode "invalid integer" to integer',
'status': 'Value not in allowed values("active", "super_active"): "invalid status"',
'items': 'Cannot decode "invalid integer" to integer',
'items_alias': 'Cannot decode "invalid integer" to integer',
'non_field_error': 'Missing fields: "name"',
}
}
Expand Down
16 changes: 6 additions & 10 deletions winter/core/utils/typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,9 @@
from typing import Iterable
from typing import TypeVar
from typing import Union
from typing import get_args
from typing import get_origin

NoneType = type(None)
UnionType = type(Union)


def is_optional(type_: object) -> bool:
Expand All @@ -30,21 +29,15 @@ def is_iterable_type(type_: object) -> bool:


def is_union(type_: object) -> bool:
return get_origin_type(type_) == Union
return get_origin_type(type_) in (Union, types.UnionType)


def get_union_args(type_: object) -> list:
return getattr(type_, '__args__', []) or []


def get_origin_type(hint_class):
if hasattr(types, 'UnionType') and isinstance(hint_class, types.UnionType):
# Extract the arguments of the union (e.g., `str | int` -> (str, int))
args = get_args(hint_class)
# Convert to the old `typing.Union` style
hint_class = Union[args]

return getattr(hint_class, '__origin__', None) or hint_class
return get_origin(hint_class) or hint_class


def is_origin_type_subclasses(hint_class, check_class):
Expand All @@ -66,4 +59,7 @@ def get_type_name(type_):
type_name = type_name[7:]
return type_name

if type(type_) in (types.GenericAlias, types.UnionType):
return type_name

return type(type_).__name__
Loading