Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
c4b9974
Add deserialization to keywords in pyproject.toml
Jan 22, 2026
0794462
Add support for date and datetime string deserialization with type hints
Jan 23, 2026
e777993
Add TODO to abstract fix_list, fix_tuple, fix_dict functions
Jan 23, 2026
467b322
Add tests for deserializing datetime and date collections
Jan 23, 2026
fab389b
Convert empty type hint argument collection to tuple
Jan 26, 2026
fc74e7c
Remove an empty line
Jan 26, 2026
0628f78
Fix tuple type handling
Jan 26, 2026
dc37759
Add tests for invalid collection content in from_string
Jan 26, 2026
47e5156
Add type ignore annotations to fix type checking issues
Jan 26, 2026
ee8135a
Fix tuple deserialization description to remove incorrect note about
Jan 26, 2026
a716ba5
Add support for ISO 8601 date and datetime strings
Jan 26, 2026
2018dbc
Mpre test cases
Jan 26, 2026
20f13cc
Add early return for unsupported types in fix_iterable_types
Jan 26, 2026
72fc4fe
Add denial>=0.0.5 to dependencies
Jan 26, 2026
30d15fc
Add support for SentinelType and InnerNoneType
Jan 26, 2026
d8ed3b5
Add test for SentinelType checks with various inputs
Jan 26, 2026
62ea625
Add InnerNoneType tests
Jan 26, 2026
cda18ed
Remove SentinelType check from type validation
Jan 26, 2026
3b0da66
Remove unused SentinelType import from check.py
Jan 26, 2026
7a3cbb2
Remove trailing newline
Jan 28, 2026
a29bcc2
Add support for denial InnerNoneType in simtypes
Jan 29, 2026
85bd7d6
Bump version to 0.0.10
Jan 29, 2026
e77c915
Add documentation link for denial library sentinels
Jan 29, 2026
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
24 changes: 21 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,15 @@ print(check(-11, NonNegativeInt))
#> False
```

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

print(check(InnerNoneType('key'), InnerNoneType('key')))
#> True
```


## String deserialization

Expand All @@ -177,8 +186,9 @@ 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. 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:
Expand Down Expand Up @@ -222,13 +232,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.
9 changes: 6 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@ 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"
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',
Expand All @@ -30,7 +33,7 @@ classifiers = [
'Intended Audience :: Developers',
'Topic :: Software Development :: Libraries',
]
keywords = ['type check']
keywords = ['type check', 'deserialization']

[tool.setuptools.package-data]
"simtypes" = ["py.typed"]
Expand Down
5 changes: 5 additions & 0 deletions simtypes/check.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

from typing import List, Type, Union, Any, get_args, get_origin

from denial import InnerNoneType

from simtypes.typing import ExpectedType


Expand All @@ -26,6 +28,9 @@ def check(value: Any, type_hint: Type[ExpectedType], strict: bool = False, lists
elif type_hint is None:
return value is None

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:
Expand Down
214 changes: 189 additions & 25 deletions simtypes/from_string.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -57,7 +37,191 @@ 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) # 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) # type: ignore[return-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.')


# 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

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 tuple(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):
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)
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[Dict[Hashable, 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) -> Optional[Union[List[Any], Tuple[Any, ...], Dict[Hashable, Any]]]:
if list in (origin_type, expected_type):
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) # type: ignore[assignment, arg-type]
if result is not None:
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


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 = 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 # type: ignore[no-any-return]
else:
raise error

return convert_single_value(value, expected_type)
Loading
Loading