From c4b99744592ce8f0cffd8ef7c4fa0bcf1499d7b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Thu, 22 Jan 2026 16:50:39 +0300 Subject: [PATCH 01/23] Add deserialization to keywords in pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f8cb12b..2cc05e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ classifiers = [ 'Intended Audience :: Developers', 'Topic :: Software Development :: Libraries', ] -keywords = ['type check'] +keywords = ['type check', 'deserialization'] [tool.setuptools.package-data] "simtypes" = ["py.typed"] From 0794462044f51686df0b4d8223242e45182a613f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Fri, 23 Jan 2026 17:16:43 +0300 Subject: [PATCH 02/23] Add support for date and datetime string deserialization with type hints --- simtypes/from_string.py | 211 ++++++++++++++++++++++++++++---- tests/units/test_from_string.py | 45 +++++-- 2 files changed, 222 insertions(+), 34 deletions(-) diff --git a/simtypes/from_string.py b/simtypes/from_string.py index dc86480..7d6a3fd 100644 --- a/simtypes/from_string.py +++ b/simtypes/from_string.py @@ -1,35 +1,15 @@ -from typing import Type, Any, get_origin +from typing import List, Tuple, Dict, Type, Optional, Union, Any, get_origin, get_args from json import loads, JSONDecodeError from inspect import isclass +from datetime import datetime, date +from collections.abc import Hashable from simtypes import check from simtypes.typing import ExpectedType -def from_string(value: str, expected_type: Type[ExpectedType]) -> ExpectedType: - if not isinstance(value, str): - raise ValueError(f'You can only pass a string as a string. You passed {type(value).__name__}.') - - if expected_type is Any: # type: ignore[comparison-overlap] - return value # type: ignore[return-value] - - origin_type = get_origin(expected_type) - - if any(x in (dict, list, tuple) for x in (expected_type, origin_type)): - type_name = expected_type.__name__ if origin_type is None else origin_type.__name__ - error_message = f'The string "{value}" cannot be interpreted as a {type_name} of the specified format.' - - try: - result: ExpectedType = loads(value) - except JSONDecodeError as e: - raise TypeError(error_message) from e - - if check(result, expected_type, strict=True, lists_are_tuples=True): # type: ignore[operator] - return result - else: - raise TypeError(error_message) - - elif expected_type is str: +def convert_single_value(value: str, expected_type: Type[ExpectedType]) -> ExpectedType: + if expected_type is str: return value # type: ignore[return-value] elif expected_type is bool: @@ -57,7 +37,188 @@ def from_string(value: str, expected_type: Type[ExpectedType]) -> ExpectedType: except ValueError as e: raise TypeError(f'The string "{value}" cannot be interpreted as a floating point number.') from e + if expected_type is datetime: + try: + return datetime.fromisoformat(value) + except ValueError as e: + raise TypeError(f'The string "{value}" cannot be interpreted as a datetime object.') from e + + if expected_type is date: + try: + return date.fromisoformat(value) + except ValueError as e: + raise TypeError(f'The string "{value}" cannot be interpreted as a date object.') from e + if not isclass(expected_type): raise ValueError('The type must be a valid type object.') raise TypeError(f'Serialization of the type {expected_type.__name__} you passed is not supported. Supported types: int, float, bool, list, dict, tuple.') + + +def fix_lists(collection: List[Any], type_hint_arguments: Tuple[Any, ...]) -> Optional[List[Any]]: + if not isinstance(collection, list) or len(type_hint_arguments) >= 2: + return None + + if not len(type_hint_arguments): + return collection + + type_hint = type_hint_arguments[0] + origin_type = get_origin(type_hint) + type_hint_arguments = get_args(type_hint) + + result = [] + for element in collection: + if any(x in (dict, list, tuple) for x in (type_hint, origin_type)): + fixed_element = fix_iterable_types(element, type_hint_arguments, origin_type, type_hint) + if fixed_element is None: + return None + result.append(fixed_element) + elif type_hint is date or type_hint is datetime: + if not isinstance(element, str): + return None + try: + result.append(convert_single_value(element, type_hint)) + except TypeError: + return None + else: + result.append(element) + + return result + + +def fix_tuples(collection: List[Any], type_hint_arguments: Tuple[Any, ...]) -> Optional[Tuple[Any]]: + if not isinstance(collection, list): + return None + + if not len(type_hint_arguments): + return collection + + result = [] + + if len(type_hint_arguments) == 2 and type_hint_arguments[1] is Ellipsis: + type_hint = type_hint_arguments[0] + origin_type = get_origin(type_hint) + type_hint_arguments = get_args(type_hint) + + for element in collection: + if any(x in (dict, list, tuple) for x in (type_hint, origin_type)): + fixed_element = fix_iterable_types(element, type_hint_arguments, origin_type, type_hint) + if fixed_element is None: + return None + result.append(fixed_element) + elif type_hint is date or type_hint is datetime: + if not isinstance(element, str): + return None + try: + result.append(convert_single_value(element, type_hint)) + except TypeError: + return None + else: + result.append(element) + + else: + if len(collection) != len(type_hint_arguments): + return None + + for type_hint, element in zip(type_hint_arguments, collection): + origin_type = get_origin(type_hint) + if any(x in (dict, list, tuple) for x in (type_hint, origin_type)): + fixed_element = fix_iterable_types(element, type_hint_arguments, origin_type, type_hint) + if fixed_element is None: + return None + result.append(fixed_element) + elif type_hint is date or type_hint is datetime: + if not isinstance(element, str): + return None + try: + result.append(convert_single_value(element, type_hint)) + except TypeError: + return None + else: + result.append(element) + + return tuple(result) + + +def fix_dicts(collection: List[Any], type_hint_arguments: Tuple[Any, ...]) -> Optional[List[Any]]: + if not isinstance(collection, dict) or len(type_hint_arguments) >= 3 or len(type_hint_arguments) == 1: + return None + + if not len(type_hint_arguments): + return collection + + key_type_hint = type_hint_arguments[0] + value_type_hint = type_hint_arguments[1] + + result = {} + for key, element in collection.items(): + pair = {'key': (key, key_type_hint), 'value': (element, value_type_hint)} + pair_result = {} + + for name, meta in pair.items(): + element, type_hint = meta + origin_type = get_origin(type_hint) + type_hint_arguments = get_args(type_hint) + if any(x in (dict, list, tuple) for x in (type_hint, origin_type)): + fixed_element = fix_iterable_types(element, type_hint_arguments, origin_type, type_hint) + if fixed_element is None: + return None + subresult = fixed_element + elif type_hint is date or type_hint is datetime: + if not isinstance(element, str): + return None + try: + subresult = convert_single_value(element, type_hint) + except TypeError: + return None + else: + subresult = element + pair_result[name] = subresult + + result[pair_result['key']] = pair_result['value'] + + return result + + +def fix_iterable_types(collection: Union[List[Any], Tuple[Any, ...], Dict[Hashable, Any]], type_hint_arguments: Tuple[Any, ...], origin_type: Any, expected_type: Any) -> ExpectedType: + if list in (origin_type, expected_type): + + result = fix_lists(collection, type_hint_arguments) + elif tuple in (origin_type, expected_type): + result = fix_tuples(collection, type_hint_arguments) + if result is not None: + result = tuple(result) + elif dict in (origin_type, expected_type): + result = fix_dicts(collection, type_hint_arguments) + + return result + + +def from_string(value: str, expected_type: Type[ExpectedType]) -> ExpectedType: + if not isinstance(value, str): + raise ValueError(f'You can only pass a string as a string. You passed {type(value).__name__}.') + + if expected_type is Any: # type: ignore[comparison-overlap] + return value # type: ignore[return-value] + + origin_type = get_origin(expected_type) + + if any(x in (dict, list, tuple) for x in (expected_type, origin_type)): + type_name = expected_type.__name__ if origin_type is None else origin_type.__name__ + error = TypeError(f'The string "{value}" cannot be interpreted as a {type_name} of the specified format.') + + try: + result: ExpectedType = loads(value) + except JSONDecodeError as e: + raise error from e + + result = fix_iterable_types(result, get_args(expected_type), origin_type, expected_type) + if result is None: + raise error + + if check(result, expected_type, strict=True): # type: ignore[operator] + return result + else: + raise error + + return convert_single_value(value, expected_type) diff --git a/tests/units/test_from_string.py b/tests/units/test_from_string.py index 238b0c1..24f65c4 100644 --- a/tests/units/test_from_string.py +++ b/tests/units/test_from_string.py @@ -1,5 +1,7 @@ from math import inf, isnan from typing import Any +from datetime import date, datetime +from json import dumps import pytest from full_match import match @@ -171,17 +173,17 @@ def test_get_list_value(list_type, subscribable_dict_type, subscribable_list_typ def test_get_tuple_value(tuple_type, subscribable_tuple_type, subscribable_dict_type): - assert from_string('[]', tuple_type) == [] - assert from_string('[]', subscribable_tuple_type[int, ...]) == [] - assert from_string('[]', subscribable_tuple_type[str, ...]) == [] + assert from_string('[]', tuple_type) == () + assert from_string('[]', subscribable_tuple_type[int, ...]) == () + assert from_string('[]', subscribable_tuple_type[str, ...]) == () - assert from_string('[1, 2, 3]', subscribable_tuple_type[int, ...]) == [1, 2, 3] - assert from_string('["lol", "kek"]', subscribable_tuple_type[str, ...]) == ["lol", "kek"] - assert from_string('[1, 2, 3]', subscribable_tuple_type[int, int, int]) == [1, 2, 3] - assert from_string('["lol", "kek"]', subscribable_tuple_type[str, str]) == ["lol", "kek"] + assert from_string('[1, 2, 3]', subscribable_tuple_type[int, ...]) == (1, 2, 3) + assert from_string('["lol", "kek"]', subscribable_tuple_type[str, ...]) == ("lol", "kek") + assert from_string('[1, 2, 3]', subscribable_tuple_type[int, int, int]) == (1, 2, 3) + assert from_string('["lol", "kek"]', subscribable_tuple_type[str, str]) == ("lol", "kek") - assert from_string('[["lol", "kek"], ["lol", "kek"]]', subscribable_tuple_type[subscribable_tuple_type[str, str], ...]) == [["lol", "kek"], ["lol", "kek"]] - assert from_string('[{"lol": "kek"}, {"lol": "kek"}]', subscribable_tuple_type[subscribable_dict_type[str, str], ...]) == [{'lol': 'kek'}, {'lol': 'kek'}] + assert from_string('[["lol", "kek"], ["lol", "kek"]]', subscribable_tuple_type[subscribable_tuple_type[str, str], ...]) == (("lol", "kek"), ("lol", "kek")) + assert from_string('[{"lol": "kek"}, {"lol": "kek"}]', subscribable_tuple_type[subscribable_dict_type[str, str], ...]) == ({'lol': 'kek'}, {'lol': 'kek'}) with pytest.raises(TypeError, match=match('The string "[]" cannot be interpreted as a tuple of the specified format.')): from_string('[]', subscribable_tuple_type[int]) @@ -300,3 +302,28 @@ def test_get_dict_value(dict_type, subscribable_list_type, subscribable_dict_typ ) def test_get_any(string): assert from_string(string, Any) == string + + +def test_deserialize_date(): + isoformatted_date = date(2026, 1, 22).isoformat() + + assert from_string(isoformatted_date, date) == date.fromisoformat(isoformatted_date) + + with pytest.raises(TypeError, match=match('The string "kek" cannot be interpreted as a date object.')): + from_string('kek', date) + + +def test_deserialize_datetetime(): + isoformatted_datetime = datetime.now().isoformat() + + assert from_string(isoformatted_datetime, datetime) == datetime.fromisoformat(isoformatted_datetime) + + with pytest.raises(TypeError, match=match('The string "kek" cannot be interpreted as a datetime object.')): + from_string('kek', datetime) + + +def test_deserialize_list_or_tuple_with_one_datetetime(subscribable_list_type, subscribable_tuple_type): + isoformatted_datetime = datetime.now().isoformat() + + assert from_string(dumps([isoformatted_datetime]), subscribable_list_type[datetime]) == [datetime.fromisoformat(isoformatted_datetime)] + assert from_string(dumps([isoformatted_datetime]), subscribable_tuple_type[datetime]) == (datetime.fromisoformat(isoformatted_datetime),) From e777993f177896f48449a416845b5a649280bf2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Fri, 23 Jan 2026 17:43:53 +0300 Subject: [PATCH 03/23] Add TODO to abstract fix_list, fix_tuple, fix_dict functions --- simtypes/from_string.py | 1 + 1 file changed, 1 insertion(+) diff --git a/simtypes/from_string.py b/simtypes/from_string.py index 7d6a3fd..815a973 100644 --- a/simtypes/from_string.py +++ b/simtypes/from_string.py @@ -55,6 +55,7 @@ def convert_single_value(value: str, expected_type: Type[ExpectedType]) -> Expec raise TypeError(f'Serialization of the type {expected_type.__name__} you passed is not supported. Supported types: int, float, bool, list, dict, tuple.') +# TODO: try to abstract fix_lists(), fix_tuples() and fix_dicts() to one function def fix_lists(collection: List[Any], type_hint_arguments: Tuple[Any, ...]) -> Optional[List[Any]]: if not isinstance(collection, list) or len(type_hint_arguments) >= 2: return None From 467b3222a32b16f1bfbf2599feb9ca49bc944034 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Fri, 23 Jan 2026 19:13:27 +0300 Subject: [PATCH 04/23] Add tests for deserializing datetime and date collections --- tests/units/test_from_string.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/units/test_from_string.py b/tests/units/test_from_string.py index 24f65c4..726da05 100644 --- a/tests/units/test_from_string.py +++ b/tests/units/test_from_string.py @@ -322,8 +322,21 @@ def test_deserialize_datetetime(): from_string('kek', datetime) -def test_deserialize_list_or_tuple_with_one_datetetime(subscribable_list_type, subscribable_tuple_type): +def test_deserialize_subscribable_collections_with_datetimes(subscribable_list_type, subscribable_tuple_type, subscribable_dict_type): isoformatted_datetime = datetime.now().isoformat() assert from_string(dumps([isoformatted_datetime]), subscribable_list_type[datetime]) == [datetime.fromisoformat(isoformatted_datetime)] assert from_string(dumps([isoformatted_datetime]), subscribable_tuple_type[datetime]) == (datetime.fromisoformat(isoformatted_datetime),) + assert from_string(dumps({isoformatted_datetime: isoformatted_datetime}), subscribable_dict_type[datetime, datetime]) == {datetime.fromisoformat(isoformatted_datetime): datetime.fromisoformat(isoformatted_datetime)} + assert from_string(dumps({isoformatted_datetime: isoformatted_datetime}), subscribable_dict_type[datetime, str]) == {datetime.fromisoformat(isoformatted_datetime): isoformatted_datetime} + assert from_string(dumps({isoformatted_datetime: isoformatted_datetime}), subscribable_dict_type[str, datetime]) == {isoformatted_datetime: datetime.fromisoformat(isoformatted_datetime)} + + +def test_deserialize_subscribable_collections_with_dates(subscribable_list_type, subscribable_tuple_type, subscribable_dict_type): + isoformatted_date = date(2026, 1, 22).isoformat() + + assert from_string(dumps([isoformatted_date]), subscribable_list_type[date]) == [date.fromisoformat(isoformatted_date)] + assert from_string(dumps([isoformatted_date]), subscribable_tuple_type[date]) == (date.fromisoformat(isoformatted_date),) + assert from_string(dumps({isoformatted_date: isoformatted_date}), subscribable_dict_type[date, date]) == {date.fromisoformat(isoformatted_date): date.fromisoformat(isoformatted_date)} + assert from_string(dumps({isoformatted_date: isoformatted_date}), subscribable_dict_type[date, str]) == {date.fromisoformat(isoformatted_date): isoformatted_date} + assert from_string(dumps({isoformatted_date: isoformatted_date}), subscribable_dict_type[str, date]) == {isoformatted_date: date.fromisoformat(isoformatted_date)} From fab389b80172217ff5b2f6795422caa14853b635 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Mon, 26 Jan 2026 23:16:32 +0300 Subject: [PATCH 05/23] Convert empty type hint argument collection to tuple --- simtypes/from_string.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/simtypes/from_string.py b/simtypes/from_string.py index 815a973..173daf0 100644 --- a/simtypes/from_string.py +++ b/simtypes/from_string.py @@ -92,7 +92,7 @@ def fix_tuples(collection: List[Any], type_hint_arguments: Tuple[Any, ...]) -> O return None if not len(type_hint_arguments): - return collection + return tuple(collection) result = [] From fc74e7c3e5071597cd3b80fa5dc1b0fb0f3ef839 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Mon, 26 Jan 2026 23:18:44 +0300 Subject: [PATCH 06/23] Remove an empty line --- simtypes/from_string.py | 1 - 1 file changed, 1 deletion(-) diff --git a/simtypes/from_string.py b/simtypes/from_string.py index 173daf0..29c76ac 100644 --- a/simtypes/from_string.py +++ b/simtypes/from_string.py @@ -183,7 +183,6 @@ def fix_dicts(collection: List[Any], type_hint_arguments: Tuple[Any, ...]) -> Op def fix_iterable_types(collection: Union[List[Any], Tuple[Any, ...], Dict[Hashable, Any]], type_hint_arguments: Tuple[Any, ...], origin_type: Any, expected_type: Any) -> ExpectedType: if list in (origin_type, expected_type): - result = fix_lists(collection, type_hint_arguments) elif tuple in (origin_type, expected_type): result = fix_tuples(collection, type_hint_arguments) From 0628f78b4b1440ee8d792ade30756f10a966a7a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Tue, 27 Jan 2026 00:31:32 +0300 Subject: [PATCH 07/23] Fix tuple type handling --- simtypes/from_string.py | 1 + 1 file changed, 1 insertion(+) diff --git a/simtypes/from_string.py b/simtypes/from_string.py index 29c76ac..34e1e7b 100644 --- a/simtypes/from_string.py +++ b/simtypes/from_string.py @@ -122,6 +122,7 @@ def fix_tuples(collection: List[Any], type_hint_arguments: Tuple[Any, ...]) -> O return None for type_hint, element in zip(type_hint_arguments, collection): + type_hint_arguments = get_args(type_hint) origin_type = get_origin(type_hint) if any(x in (dict, list, tuple) for x in (type_hint, origin_type)): fixed_element = fix_iterable_types(element, type_hint_arguments, origin_type, type_hint) From dc377590f88028e993668f5006ce382c649c2f05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Tue, 27 Jan 2026 01:06:40 +0300 Subject: [PATCH 08/23] Add tests for invalid collection content in from_string --- tests/units/test_from_string.py | 146 ++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) diff --git a/tests/units/test_from_string.py b/tests/units/test_from_string.py index 726da05..6a3ba1e 100644 --- a/tests/units/test_from_string.py +++ b/tests/units/test_from_string.py @@ -182,6 +182,9 @@ def test_get_tuple_value(tuple_type, subscribable_tuple_type, subscribable_dict_ assert from_string('[1, 2, 3]', subscribable_tuple_type[int, int, int]) == (1, 2, 3) assert from_string('["lol", "kek"]', subscribable_tuple_type[str, str]) == ("lol", "kek") + assert from_string('[["lol", "kek"], ["lol", "kek"]]', subscribable_tuple_type[subscribable_tuple_type[str, str], subscribable_tuple_type[str, str]]) == (("lol", "kek"), ("lol", "kek")) + assert from_string('[{"lol": "kek"}, {"lol": "kek"}]', subscribable_tuple_type[subscribable_dict_type[str, str], subscribable_dict_type[str, str]]) == ({'lol': 'kek'}, {'lol': 'kek'}) + assert from_string('[["lol", "kek"], ["lol", "kek"]]', subscribable_tuple_type[subscribable_tuple_type[str, str], ...]) == (("lol", "kek"), ("lol", "kek")) assert from_string('[{"lol": "kek"}, {"lol": "kek"}]', subscribable_tuple_type[subscribable_dict_type[str, str], ...]) == ({'lol': 'kek'}, {'lol': 'kek'}) @@ -327,6 +330,7 @@ def test_deserialize_subscribable_collections_with_datetimes(subscribable_list_t assert from_string(dumps([isoformatted_datetime]), subscribable_list_type[datetime]) == [datetime.fromisoformat(isoformatted_datetime)] assert from_string(dumps([isoformatted_datetime]), subscribable_tuple_type[datetime]) == (datetime.fromisoformat(isoformatted_datetime),) + assert from_string(dumps([isoformatted_datetime]), subscribable_tuple_type[datetime, ...]) == (datetime.fromisoformat(isoformatted_datetime),) assert from_string(dumps({isoformatted_datetime: isoformatted_datetime}), subscribable_dict_type[datetime, datetime]) == {datetime.fromisoformat(isoformatted_datetime): datetime.fromisoformat(isoformatted_datetime)} assert from_string(dumps({isoformatted_datetime: isoformatted_datetime}), subscribable_dict_type[datetime, str]) == {datetime.fromisoformat(isoformatted_datetime): isoformatted_datetime} assert from_string(dumps({isoformatted_datetime: isoformatted_datetime}), subscribable_dict_type[str, datetime]) == {isoformatted_datetime: datetime.fromisoformat(isoformatted_datetime)} @@ -337,6 +341,148 @@ def test_deserialize_subscribable_collections_with_dates(subscribable_list_type, assert from_string(dumps([isoformatted_date]), subscribable_list_type[date]) == [date.fromisoformat(isoformatted_date)] assert from_string(dumps([isoformatted_date]), subscribable_tuple_type[date]) == (date.fromisoformat(isoformatted_date),) + assert from_string(dumps([isoformatted_date]), subscribable_tuple_type[date, ...]) == (date.fromisoformat(isoformatted_date),) assert from_string(dumps({isoformatted_date: isoformatted_date}), subscribable_dict_type[date, date]) == {date.fromisoformat(isoformatted_date): date.fromisoformat(isoformatted_date)} assert from_string(dumps({isoformatted_date: isoformatted_date}), subscribable_dict_type[date, str]) == {date.fromisoformat(isoformatted_date): isoformatted_date} assert from_string(dumps({isoformatted_date: isoformatted_date}), subscribable_dict_type[str, date]) == {isoformatted_date: date.fromisoformat(isoformatted_date)} + + +def test_wrong_collection_content(subscribable_list_type, subscribable_tuple_type, subscribable_dict_type): + with pytest.raises(TypeError, match=match('The string "[123]" cannot be interpreted as a list of the specified format.')): + from_string(dumps([123]), subscribable_list_type[date]) + + with pytest.raises(TypeError, match=match('The string "[123]" cannot be interpreted as a list of the specified format.')): + from_string(dumps([123]), subscribable_list_type[datetime]) + + with pytest.raises(TypeError, match=match('The string "[null]" cannot be interpreted as a list of the specified format.')): + from_string(dumps([None]), subscribable_list_type[datetime]) + + with pytest.raises(TypeError, match=match('The string "[null]" cannot be interpreted as a list of the specified format.')): + from_string(dumps([None]), subscribable_list_type[date]) + + with pytest.raises(TypeError, match=match('The string "["123"]" cannot be interpreted as a list of the specified format.')): + from_string(dumps(['123']), subscribable_list_type[date]) + + with pytest.raises(TypeError, match=match('The string "["123"]" cannot be interpreted as a list of the specified format.')): + from_string(dumps(['123']), subscribable_list_type[datetime]) + + + with pytest.raises(TypeError, match=match('The string "[123]" cannot be interpreted as a tuple of the specified format.')): + from_string(dumps([123]), subscribable_tuple_type[date]) + + with pytest.raises(TypeError, match=match('The string "[123]" cannot be interpreted as a tuple of the specified format.')): + from_string(dumps([123]), subscribable_tuple_type[datetime]) + + with pytest.raises(TypeError, match=match('The string "[null]" cannot be interpreted as a tuple of the specified format.')): + from_string(dumps([None]), subscribable_tuple_type[datetime]) + + with pytest.raises(TypeError, match=match('The string "[null]" cannot be interpreted as a tuple of the specified format.')): + from_string(dumps([None]), subscribable_tuple_type[date]) + + with pytest.raises(TypeError, match=match('The string "["123"]" cannot be interpreted as a tuple of the specified format.')): + from_string(dumps(['123']), subscribable_tuple_type[datetime]) + + with pytest.raises(TypeError, match=match('The string "["123"]" cannot be interpreted as a tuple of the specified format.')): + from_string(dumps(['123']), subscribable_tuple_type[date]) + + + with pytest.raises(TypeError, match=match('The string "[123]" cannot be interpreted as a tuple of the specified format.')): + from_string(dumps([123]), subscribable_tuple_type[date, ...]) + + with pytest.raises(TypeError, match=match('The string "[123]" cannot be interpreted as a tuple of the specified format.')): + from_string(dumps([123]), subscribable_tuple_type[datetime, ...]) + + with pytest.raises(TypeError, match=match('The string "[null]" cannot be interpreted as a tuple of the specified format.')): + from_string(dumps([None]), subscribable_tuple_type[datetime, ...]) + + with pytest.raises(TypeError, match=match('The string "[null]" cannot be interpreted as a tuple of the specified format.')): + from_string(dumps([None]), subscribable_tuple_type[date, ...]) + + with pytest.raises(TypeError, match=match('The string "["123"]" cannot be interpreted as a tuple of the specified format.')): + from_string(dumps(['123']), subscribable_tuple_type[datetime, ...]) + + with pytest.raises(TypeError, match=match('The string "["123"]" cannot be interpreted as a tuple of the specified format.')): + from_string(dumps(['123']), subscribable_tuple_type[date, ...]) + + + with pytest.raises(TypeError, match=match('The string "{"kek": 123}" cannot be interpreted as a tuple of the specified format.')): + from_string(dumps({'kek': 123}), subscribable_tuple_type[date]) + + with pytest.raises(TypeError, match=match('The string "{"kek": 123}" cannot be interpreted as a tuple of the specified format.')): + from_string(dumps({'kek': 123}), subscribable_tuple_type[date, ...]) + + with pytest.raises(TypeError, match=match('The string "{"kek": 123}" cannot be interpreted as a list of the specified format.')): + from_string(dumps({'kek': 123}), subscribable_list_type[date]) + + + with pytest.raises(TypeError, match=match('The string "[{"kek": "lol"}]" cannot be interpreted as a list of the specified format.')): + from_string(dumps([{'kek': 'lol'}]), subscribable_list_type[subscribable_dict_type[str, int]]) + + with pytest.raises(TypeError, match=match('The string "[{"kek": "lol"}]" cannot be interpreted as a list of the specified format.')): + from_string(dumps([{'kek': 'lol'}]), subscribable_list_type[subscribable_list_type[int]]) + + + with pytest.raises(TypeError, match=match('The string "[{"kek": "lol"}]" cannot be interpreted as a tuple of the specified format.')): + from_string(dumps([{'kek': 'lol'}]), subscribable_tuple_type[subscribable_dict_type[str, int]]) + + with pytest.raises(TypeError, match=match('The string "[{"kek": "lol"}]" cannot be interpreted as a tuple of the specified format.')): + from_string(dumps([{'kek': 'lol'}]), subscribable_tuple_type[subscribable_list_type[int]]) + + with pytest.raises(TypeError, match=match('The string "[{"kek": "lol"}]" cannot be interpreted as a tuple of the specified format.')): + from_string(dumps([{'kek': 'lol'}]), subscribable_tuple_type[subscribable_list_type[int], ...]) + + with pytest.raises(TypeError, match=match('The string "[{"kek": "lol"}]" cannot be interpreted as a tuple of the specified format.')): + from_string(dumps([{'kek': 'lol'}]), subscribable_tuple_type[subscribable_dict_type[str, int], ...]) + + + with pytest.raises(TypeError, match=match('The string "[123]" cannot be interpreted as a dict of the specified format.')): + from_string(dumps([123]), subscribable_dict_type[int, int]) + + with pytest.raises(TypeError, match=match('The string "["123"]" cannot be interpreted as a dict of the specified format.')): + from_string(dumps(['123']), subscribable_dict_type[int, int]) + + with pytest.raises(TypeError, match=match('The string "{"123": "123"}" cannot be interpreted as a dict of the specified format.')): + from_string(dumps({123: '123'}), subscribable_dict_type[int, int]) + + with pytest.raises(TypeError, match=match('The string "{"123": 123}" cannot be interpreted as a dict of the specified format.')): + from_string(dumps({'123': 123}), subscribable_dict_type[int, int]) + + with pytest.raises(TypeError, match=match('The string "{"123": {"123": "123"}}" cannot be interpreted as a dict of the specified format.')): + from_string(dumps({'123': {123: '123'}}), subscribable_dict_type[str, subscribable_dict_type[int, int]]) + + with pytest.raises(TypeError, match=match('The string "{"123": [123, 123]}" cannot be interpreted as a dict of the specified format.')): + from_string(dumps({'123': [123, 123]}), subscribable_dict_type[str, subscribable_dict_type[int, int]]) + + with pytest.raises(TypeError, match=match('The string "{"123": {"123": "123"}}" cannot be interpreted as a dict of the specified format.')): + from_string(dumps({'123': {123: '123'}}), subscribable_dict_type[str, subscribable_list_type[int]]) + + + with pytest.raises(TypeError, match=match('The string "[123]" cannot be interpreted as a dict of the specified format.')): + from_string(dumps([123]), subscribable_dict_type[int, date]) + + with pytest.raises(TypeError, match=match('The string "[123]" cannot be interpreted as a dict of the specified format.')): + from_string(dumps([123]), subscribable_dict_type[int, datetime]) + + with pytest.raises(TypeError, match=match('The string "{"123": "123"}" cannot be interpreted as a dict of the specified format.')): + from_string(dumps({123: '123'}), subscribable_dict_type[int, datetime]) + + with pytest.raises(TypeError, match=match('The string "{"123": "123"}" cannot be interpreted as a dict of the specified format.')): + from_string(dumps({123: '123'}), subscribable_dict_type[datetime, str]) + + with pytest.raises(TypeError, match=match('The string "{"123": "123"}" cannot be interpreted as a dict of the specified format.')): + from_string(dumps({123: '123'}), subscribable_dict_type[int, date]) + + with pytest.raises(TypeError, match=match('The string "{"123": "123"}" cannot be interpreted as a dict of the specified format.')): + from_string(dumps({123: '123'}), subscribable_dict_type[date, str]) + + with pytest.raises(TypeError, match=match('The string "{"123": null}" cannot be interpreted as a dict of the specified format.')): + from_string(dumps({'123': None}), subscribable_dict_type[int, datetime]) + + with pytest.raises(TypeError, match=match('The string "{"123": null}" cannot be interpreted as a dict of the specified format.')): + from_string(dumps({'123': None}), subscribable_dict_type[int, date]) + + with pytest.raises(TypeError, match=match('The string "{"123": 123}" cannot be interpreted as a dict of the specified format.')): + from_string(dumps({'123': 123}), subscribable_dict_type[int, datetime]) + + with pytest.raises(TypeError, match=match('The string "{"123": 123}" cannot be interpreted as a dict of the specified format.')): + from_string(dumps({'123': 123}), subscribable_dict_type[int, date]) From 47e515616c7eb2cf82c6f719b178e3848bddd056 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Tue, 27 Jan 2026 01:37:04 +0300 Subject: [PATCH 09/23] Add type ignore annotations to fix type checking issues --- simtypes/from_string.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/simtypes/from_string.py b/simtypes/from_string.py index 34e1e7b..edaf7f2 100644 --- a/simtypes/from_string.py +++ b/simtypes/from_string.py @@ -39,13 +39,13 @@ def convert_single_value(value: str, expected_type: Type[ExpectedType]) -> Expec if expected_type is datetime: try: - return datetime.fromisoformat(value) + return datetime.fromisoformat(value) # type: ignore[return-value] except ValueError as e: raise TypeError(f'The string "{value}" cannot be interpreted as a datetime object.') from e if expected_type is date: try: - return date.fromisoformat(value) + return date.fromisoformat(value) # type: ignore[return-value] except ValueError as e: raise TypeError(f'The string "{value}" cannot be interpreted as a date object.') from e @@ -87,7 +87,7 @@ def fix_lists(collection: List[Any], type_hint_arguments: Tuple[Any, ...]) -> Op return result -def fix_tuples(collection: List[Any], type_hint_arguments: Tuple[Any, ...]) -> Optional[Tuple[Any]]: +def fix_tuples(collection: List[Any], type_hint_arguments: Tuple[Any, ...]) -> Optional[Tuple[Any, ...]]: if not isinstance(collection, list): return None @@ -142,7 +142,7 @@ def fix_tuples(collection: List[Any], type_hint_arguments: Tuple[Any, ...]) -> O return tuple(result) -def fix_dicts(collection: List[Any], type_hint_arguments: Tuple[Any, ...]) -> Optional[List[Any]]: +def fix_dicts(collection: List[Any], type_hint_arguments: Tuple[Any, ...]) -> Optional[Dict[Hashable, Any]]: if not isinstance(collection, dict) or len(type_hint_arguments) >= 3 or len(type_hint_arguments) == 1: return None @@ -182,15 +182,15 @@ def fix_dicts(collection: List[Any], type_hint_arguments: Tuple[Any, ...]) -> Op return result -def fix_iterable_types(collection: Union[List[Any], Tuple[Any, ...], Dict[Hashable, Any]], type_hint_arguments: Tuple[Any, ...], origin_type: Any, expected_type: Any) -> ExpectedType: +def fix_iterable_types(collection: Union[List[Any], Tuple[Any, ...], Dict[Hashable, Any]], type_hint_arguments: Tuple[Any, ...], origin_type: Any, expected_type: Any) -> Optional[Union[List[Any], Tuple[Any, ...], Dict[Hashable, Any]]]: if list in (origin_type, expected_type): - result = fix_lists(collection, type_hint_arguments) + result = fix_lists(collection, type_hint_arguments) # type: ignore[arg-type] elif tuple in (origin_type, expected_type): - result = fix_tuples(collection, type_hint_arguments) + result = fix_tuples(collection, type_hint_arguments) # type: ignore[assignment, arg-type] if result is not None: - result = tuple(result) + result = tuple(result) # type: ignore[assignment] elif dict in (origin_type, expected_type): - result = fix_dicts(collection, type_hint_arguments) + result = fix_dicts(collection, type_hint_arguments) # type: ignore[assignment, arg-type] return result @@ -209,7 +209,7 @@ def from_string(value: str, expected_type: Type[ExpectedType]) -> ExpectedType: error = TypeError(f'The string "{value}" cannot be interpreted as a {type_name} of the specified format.') try: - result: ExpectedType = loads(value) + result = loads(value) except JSONDecodeError as e: raise error from e @@ -218,7 +218,7 @@ def from_string(value: str, expected_type: Type[ExpectedType]) -> ExpectedType: raise error if check(result, expected_type, strict=True): # type: ignore[operator] - return result + return result # type: ignore[no-any-return] else: raise error From ee8135a0194e4bd6f1802818aaa8645081c88c51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Tue, 27 Jan 2026 01:42:55 +0300 Subject: [PATCH 10/23] Fix tuple deserialization description to remove incorrect note about returned value type --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9bfc360..35e9c8b 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,7 @@ The library also provides primitive deserialization. Conversion of strings into - `float` - any floating-point numbers, including infinities and [`NaN`](https://en.wikipedia.org/wiki/NaN). - `bool`- the strings `"yes"`, `"True"`, and `"true"` are interpreted as `True`, while `"no"`, `"False"`, or `"false"` are interpreted as `False`. - `list` - lists in [`json`](https://en.wikipedia.org/wiki/JSON) format are expected. -- `tuple` - lists in [`json`](https://en.wikipedia.org/wiki/JSON) format are expected. This is the only type where the value produced does not match the passed type, the returned value is always a list. +- `tuple` - lists in [`json`](https://en.wikipedia.org/wiki/JSON) format are expected. - `dict` - dicts in [`json`](https://en.wikipedia.org/wiki/JSON) format are expected. Examples: From a716ba57a32a28f849c964e994c85103612ccb44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Tue, 27 Jan 2026 01:55:12 +0300 Subject: [PATCH 11/23] Add support for ISO 8601 date and datetime strings --- README.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 35e9c8b..8329f94 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,7 @@ The library also provides primitive deserialization. Conversion of strings into - `int` - any integers. - `float` - any floating-point numbers, including infinities and [`NaN`](https://en.wikipedia.org/wiki/NaN). - `bool`- the strings `"yes"`, `"True"`, and `"true"` are interpreted as `True`, while `"no"`, `"False"`, or `"false"` are interpreted as `False`. +- `date` or `datetime` - strings representing, respectively, dates or dates + time in [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) format. - `list` - lists in [`json`](https://en.wikipedia.org/wiki/JSON) format are expected. - `tuple` - lists in [`json`](https://en.wikipedia.org/wiki/JSON) format are expected. - `dict` - dicts in [`json`](https://en.wikipedia.org/wiki/JSON) format are expected. @@ -222,13 +223,21 @@ print(from_string('no', bool)) print(from_string('True', bool)) #> True +# dates and datetimes +from datetime import datetime, date + +print(from_string('2026-01-27', date)) +#> 2026-01-27 +print(from_string('2026-01-27 01:47:29.982044', datetime)) +#> 2026-01-27 01:47:29.982044 + # collections print(from_string('[1, 2, 3]', list[int])) #> [1, 2, 3] print(from_string('[1, 2, 3]', tuple[int, ...])) -#> [1, 2, 3] +#> (1, 2, 3) print(from_string('{"123": [1, 2, 3]}', dict[str, tuple[int, ...]])) -#> {"123": [1, 2, 3]} +#> {'123': (1, 2, 3)} ``` > 👀 If the passed string cannot be interpreted as an object of the specified type, a `TypeError` exception will be raised. From 2018dbc6839a1933119f18657f4eb96c5d5de2a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Tue, 27 Jan 2026 02:01:31 +0300 Subject: [PATCH 12/23] Mpre test cases --- tests/units/test_from_string.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/units/test_from_string.py b/tests/units/test_from_string.py index 6a3ba1e..72d9544 100644 --- a/tests/units/test_from_string.py +++ b/tests/units/test_from_string.py @@ -129,6 +129,7 @@ def test_get_bool_value(): def test_get_list_value(list_type, subscribable_dict_type, subscribable_list_type): assert from_string('[]', list_type) == [] + assert from_string('[1, 2, 3]', list_type) == [1, 2, 3] assert from_string('[]', subscribable_list_type[int]) == [] assert from_string('[]', subscribable_list_type[str]) == [] @@ -174,6 +175,7 @@ def test_get_list_value(list_type, subscribable_dict_type, subscribable_list_typ def test_get_tuple_value(tuple_type, subscribable_tuple_type, subscribable_dict_type): assert from_string('[]', tuple_type) == () + assert from_string('[1, 2, 3]', tuple_type) == (1, 2, 3) assert from_string('[]', subscribable_tuple_type[int, ...]) == () assert from_string('[]', subscribable_tuple_type[str, ...]) == () @@ -230,6 +232,7 @@ def test_get_tuple_value(tuple_type, subscribable_tuple_type, subscribable_dict_ def test_get_dict_value(dict_type, subscribable_list_type, subscribable_dict_type): assert from_string('{}', dict_type) == {} + assert from_string('{"lol": "kek"}', dict_type) == {'lol': 'kek'} assert from_string('{}', subscribable_dict_type[int, int]) == {} assert from_string('{}', subscribable_dict_type[str, str]) == {} assert from_string('{}', subscribable_dict_type[int, str]) == {} @@ -347,7 +350,7 @@ def test_deserialize_subscribable_collections_with_dates(subscribable_list_type, assert from_string(dumps({isoformatted_date: isoformatted_date}), subscribable_dict_type[str, date]) == {isoformatted_date: date.fromisoformat(isoformatted_date)} -def test_wrong_collection_content(subscribable_list_type, subscribable_tuple_type, subscribable_dict_type): +def test_wrong_collection_content(subscribable_list_type, subscribable_tuple_type, subscribable_dict_type, dict_type, list_type, tuple_type): with pytest.raises(TypeError, match=match('The string "[123]" cannot be interpreted as a list of the specified format.')): from_string(dumps([123]), subscribable_list_type[date]) @@ -486,3 +489,16 @@ def test_wrong_collection_content(subscribable_list_type, subscribable_tuple_typ with pytest.raises(TypeError, match=match('The string "{"123": 123}" cannot be interpreted as a dict of the specified format.')): from_string(dumps({'123': 123}), subscribable_dict_type[int, date]) + + + with pytest.raises(TypeError, match=match('The string "123" cannot be interpreted as a dict of the specified format.')): + from_string('123', dict_type) + + with pytest.raises(TypeError, match=match('The string "[123]" cannot be interpreted as a dict of the specified format.')): + from_string('[123]', dict_type) + + with pytest.raises(TypeError, match=match('The string "123" cannot be interpreted as a list of the specified format.')): + from_string('123', list_type) + + with pytest.raises(TypeError, match=match('The string "123" cannot be interpreted as a tuple of the specified format.')): + from_string('123', tuple_type) From 20f13cc6a2bd2a7fd05b428d19a3e5aba19fd133 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Tue, 27 Jan 2026 02:06:57 +0300 Subject: [PATCH 13/23] Add early return for unsupported types in fix_iterable_types --- simtypes/from_string.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/simtypes/from_string.py b/simtypes/from_string.py index edaf7f2..a015cf0 100644 --- a/simtypes/from_string.py +++ b/simtypes/from_string.py @@ -191,6 +191,8 @@ def fix_iterable_types(collection: Union[List[Any], Tuple[Any, ...], Dict[Hashab result = tuple(result) # type: ignore[assignment] elif dict in (origin_type, expected_type): result = fix_dicts(collection, type_hint_arguments) # type: ignore[assignment, arg-type] + else: + return None # pragma: no cover return result From 72fc4fe27433f82ce8556a7bfd7ea17c67a9f6cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Tue, 27 Jan 2026 02:11:23 +0300 Subject: [PATCH 14/23] Add denial>=0.0.5 to dependencies --- pyproject.toml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2cc05e5..9a86b9c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,10 @@ authors = [{ name = "Evgeniy Blinov", email = "zheni-b@yandex.ru" }] description = 'Type checking in runtime without stupid games' readme = "README.md" requires-python = ">=3.8" -dependencies = ['typing_extensions ; python_version <= "3.12"'] +dependencies = [ + 'typing_extensions ; python_version <= "3.12"', + 'denial>=0.0.5', +] classifiers = [ "Operating System :: OS Independent", 'Operating System :: MacOS :: MacOS X', From 30d15fc1eb080b44f6110266e71a2436d6f7c74e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Tue, 27 Jan 2026 02:19:04 +0300 Subject: [PATCH 15/23] Add support for SentinelType and InnerNoneType --- simtypes/check.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/simtypes/check.py b/simtypes/check.py index fe50328..c0b1f58 100644 --- a/simtypes/check.py +++ b/simtypes/check.py @@ -13,6 +13,8 @@ from typing import List, Type, Union, Any, get_args, get_origin +from denial import InnerNoneType, SentinelType + from simtypes.typing import ExpectedType @@ -26,6 +28,12 @@ def check(value: Any, type_hint: Type[ExpectedType], strict: bool = False, lists elif type_hint is None: return value is None + elif type_hint is SentinelType: + return value is None or isinstance(value, InnerNoneType) + + elif isinstance(type_hint, InnerNoneType): + return type_hint == value + origin_type = get_origin(type_hint) if origin_type is Union or origin_type is UnionType: From d8ed3b548ef2891adad4f6cfc0d17d4c38344212 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Tue, 27 Jan 2026 02:24:58 +0300 Subject: [PATCH 16/23] Add test for SentinelType checks with various inputs --- tests/units/test_check.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/units/test_check.py b/tests/units/test_check.py index 1640172..8d7df8a 100644 --- a/tests/units/test_check.py +++ b/tests/units/test_check.py @@ -11,6 +11,7 @@ import pytest from full_match import match +from denial import InnerNone, InnerNoneType, SentinelType from simtypes import check @@ -492,3 +493,21 @@ def test_pass_mocks_when_its_off(strict_mode, list_type): assert check(Mock(), Mock, strict=strict_mode, pass_mocks=False) assert check(MagicMock(), MagicMock, strict=strict_mode, pass_mocks=False) + + +@pytest.mark.parametrize( + ['strict_mode'], + [ + (False,), + (True,), + ], +) +def test_denial_sentinel(strict_mode): + assert not check(123, SentinelType, strict=strict_mode) + assert not check('None', SentinelType, strict=strict_mode) + + assert check(None, SentinelType, strict=strict_mode) + assert check(InnerNone, SentinelType, strict=strict_mode) + assert check(InnerNoneType(), SentinelType, strict=strict_mode) + assert check(InnerNoneType(123), SentinelType, strict=strict_mode) + assert check(InnerNoneType('lol'), SentinelType, strict=strict_mode) From 62ea6256cfeb21f5ba9e9bb3ccb8376e344c791b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Tue, 27 Jan 2026 02:30:06 +0300 Subject: [PATCH 17/23] Add InnerNoneType tests --- tests/units/test_check.py | 49 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/units/test_check.py b/tests/units/test_check.py index 8d7df8a..46b44c4 100644 --- a/tests/units/test_check.py +++ b/tests/units/test_check.py @@ -511,3 +511,52 @@ def test_denial_sentinel(strict_mode): assert check(InnerNoneType(), SentinelType, strict=strict_mode) assert check(InnerNoneType(123), SentinelType, strict=strict_mode) assert check(InnerNoneType('lol'), SentinelType, strict=strict_mode) + + + +@pytest.mark.parametrize( + ['strict_mode'], + [ + (False,), + (True,), + ], +) +def test_denial_innernonetype(strict_mode): + assert not check(123, InnerNoneType, strict=strict_mode) + assert not check('None', InnerNoneType, strict=strict_mode) + assert not check(None, InnerNoneType, strict=strict_mode) + + assert check(InnerNone, InnerNoneType, strict=strict_mode) + assert check(InnerNoneType(), InnerNoneType, strict=strict_mode) + assert check(InnerNoneType(123), InnerNoneType, strict=strict_mode) + assert check(InnerNoneType('lol'), InnerNoneType, strict=strict_mode) + + +@pytest.mark.parametrize( + ['strict_mode'], + [ + (False,), + (True,), + ], +) +def test_denial_innernone(strict_mode): + assert not check(123, InnerNoneType(123), strict=strict_mode) + assert not check('None', InnerNoneType(123), strict=strict_mode) + assert not check(None, InnerNoneType(123), strict=strict_mode) + + assert not check(123, InnerNoneType('123'), strict=strict_mode) + assert not check('None', InnerNoneType('123'), strict=strict_mode) + assert not check(None, InnerNoneType('123'), strict=strict_mode) + + assert not check(123, InnerNone, strict=strict_mode) + assert not check('None', InnerNone, strict=strict_mode) + assert not check(None, InnerNone, strict=strict_mode) + + assert not check(InnerNoneType(), InnerNoneType(), strict=strict_mode) + assert not check(InnerNoneType(), InnerNone, strict=strict_mode) + assert not check(InnerNoneType(123), InnerNoneType(1234), strict=strict_mode) + assert not check(InnerNoneType('lol'), InnerNoneType('lol-kek'), strict=strict_mode) + + assert check(InnerNone, InnerNone, strict=strict_mode) + assert check(InnerNoneType(123), InnerNoneType(123), strict=strict_mode) + assert check(InnerNoneType('lol'), InnerNoneType('lol'), strict=strict_mode) From cda18ed2726a78b356c554ee146a1670f522fca7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Tue, 27 Jan 2026 02:30:28 +0300 Subject: [PATCH 18/23] Remove SentinelType check from type validation --- simtypes/check.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/simtypes/check.py b/simtypes/check.py index c0b1f58..e5f8066 100644 --- a/simtypes/check.py +++ b/simtypes/check.py @@ -28,9 +28,6 @@ def check(value: Any, type_hint: Type[ExpectedType], strict: bool = False, lists elif type_hint is None: return value is None - elif type_hint is SentinelType: - return value is None or isinstance(value, InnerNoneType) - elif isinstance(type_hint, InnerNoneType): return type_hint == value From 3b0da66ffe9f9a0e67cfcfb886fe8f860b06546d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Tue, 27 Jan 2026 02:32:49 +0300 Subject: [PATCH 19/23] Remove unused SentinelType import from check.py --- simtypes/check.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/simtypes/check.py b/simtypes/check.py index e5f8066..16f5929 100644 --- a/simtypes/check.py +++ b/simtypes/check.py @@ -13,7 +13,7 @@ from typing import List, Type, Union, Any, get_args, get_origin -from denial import InnerNoneType, SentinelType +from denial import InnerNoneType from simtypes.typing import ExpectedType From 7a3cbb254b06ad52597b856f29f1ac7c1a4d82d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Wed, 28 Jan 2026 21:29:28 +0300 Subject: [PATCH 20/23] Remove trailing newline --- tests/units/test_check.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/units/test_check.py b/tests/units/test_check.py index 46b44c4..141bb5c 100644 --- a/tests/units/test_check.py +++ b/tests/units/test_check.py @@ -513,7 +513,6 @@ def test_denial_sentinel(strict_mode): assert check(InnerNoneType('lol'), SentinelType, strict=strict_mode) - @pytest.mark.parametrize( ['strict_mode'], [ From a29bcc2b31d07c93a6f09da81e593c6abc5b7244 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Fri, 30 Jan 2026 01:02:43 +0300 Subject: [PATCH 21/23] Add support for denial InnerNoneType in simtypes --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 8329f94..51732a0 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,15 @@ print(check(-11, NonNegativeInt)) #> False ``` +In addition to other types, simtypes supports an extended type of sentinels from the `denial` library. In short, this is an extended `None`, for cases when we need to distinguish between situations where a value is undefined and situations where it is defined as undefined. Similar to `None`, objects of the `InnerNoneType` class can be used as type hints for themselves: + +```python +from denial import InnerNoneType + +print(check(InnerNoneType('key'), InnerNoneType('key'))) +#> True +``` + ## String deserialization From 85bd7d6ed1826d934ae0ad868441677ae0907349 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Fri, 30 Jan 2026 01:04:41 +0300 Subject: [PATCH 22/23] Bump version to 0.0.10 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9a86b9c..81aeca1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "simtypes" -version = "0.0.9" +version = "0.0.10" authors = [{ name = "Evgeniy Blinov", email = "zheni-b@yandex.ru" }] description = 'Type checking in runtime without stupid games' readme = "README.md" From e77c915bb7636b298e3a0f6f2e45c23cfc30e014 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Fri, 30 Jan 2026 01:08:34 +0300 Subject: [PATCH 23/23] Add documentation link for denial library sentinels --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 51732a0..f1d659d 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,7 @@ print(check(-11, NonNegativeInt)) #> False ``` -In addition to other types, simtypes supports an extended type of sentinels from the `denial` library. In short, this is an extended `None`, for cases when we need to distinguish between situations where a value is undefined and situations where it is defined as undefined. Similar to `None`, objects of the `InnerNoneType` class can be used as type hints for themselves: +In addition to other types, simtypes supports an extended type of sentinels from the [`denial`](https://github.com/pomponchik/denial/) library. In short, this is an extended `None`, for cases when we need to distinguish between situations where a value is undefined and situations where it is defined as undefined. Similar to `None`, objects of the `InnerNoneType` class can be used as type hints for themselves: ```python from denial import InnerNoneType