From 9120bb6cdf262de6ee534f5a201e942412f2c301 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, 21 Aug 2026 00:38:07 +0300 Subject: [PATCH 1/5] Add plan for supporting `None` deserialization in `from_string` --- docs/plans/1.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 docs/plans/1.md diff --git a/docs/plans/1.md b/docs/plans/1.md new file mode 100644 index 0000000..27c0a2b --- /dev/null +++ b/docs/plans/1.md @@ -0,0 +1,49 @@ +# Поддержка десериализации `None` в `from_string` + +Сейчас `from_string` не умеет десериализовывать одиночный `None`: bare-аннотация `None` считается невалидным объектом типа, а `type(None)` — неподдерживаемым типом. Нужно поддержать обе формы целевой аннотации и преобразовывать точные строки `"null"` и `"None"` в singleton `None`, сохранив точность публичной типизации и существующее поведение остальных типов и JSON-коллекций. + +## Подготовка + +- Первым изменением создать `docs/plans/1.md` и сохранить туда этот утверждённый план. +- До сохранения плана не изменять реализацию, тесты или README. + +## Реализация и публичный API + +- Добавить перегрузку `from_string(value: str, expected_type: None) -> None`. +- Сохранить обобщённую перегрузку `from_string(value: str, expected_type: Type[ExpectedType]) -> ExpectedType`; она продолжит обслуживать обычные классы и `type(None)` без отдельной перегрузки. +- Реализацию `from_string` аннотировать объединённым контрактом, принимающим `Optional[Type[ExpectedType]]`. +- После существующей проверки строкового типа входного значения направлять bare `None` в скалярный конвертер как `type(None)`. +- В существующий скалярный разбор добавить случай для `type(None)`: + - точные строки `"null"` и `"None"` возвращают singleton `None`; + - остальной текст приводит к `TypeError` с сообщением `The string "{value}" cannot be interpreted as None.`; + - регистр и внешние пробелы не нормализуются. +- Не добавлять отдельный механизм обработки ошибок: новый случай следует структуре существующих скалярных веток `bool`, `int`, `float`, `date` и `datetime`. +- Не менять обработку коллекций. Существующее поведение закрепить тестами: JSON `null` соответствует элементу или значению с аннотацией `None`, а JSON-строка `"None"` — нет. + +## Тесты + +Runtime-тесты разместить в `tests/units/test_from_string.py`. Проверки публичной статической типизации разместить отдельно в `tests/typing/test_from_string.py`, следуя существующей структуре и используя маркер `mypy_testing`. + +| Имя теста | Суть теста | Подготовка | Что ассертим | +|---|---|---|---| +| `test_value_is_not_string` (существующий тест) | Подтвердить, что поддержка `None` не меняет первичную валидацию входного значения. | Параметризовать существующий тест целевыми типами `int`, `str`, `None` и `type(None)`; передавать нестроковое значение. | Для каждого целевого типа выбрасывается существующий `ValueError` о том, что вход должен быть строкой. | +| `test_get_string_value` (существующий тест) | Убедиться, что токены `null` и `None` не преобразуются глобально и остаются строками при целевом `str`. | Параметризовать существующие строковые примеры и добавить к ним `"null"` и `"None"`. | Результат полностью совпадает с исходной строкой. | +| `test_get_any` (существующий параметризованный тест) | Убедиться, что поведение `Any` остаётся прежним. | Добавить `"null"` и `"None"` в существующий список параметров. | При целевом `Any` оба токена возвращаются как строки без преобразования. | +| `test_get_none_value` | Покрыть все поддерживаемые сочетания текста и целевой аннотации без дублирования тестов. | Декартово параметризовать тексты `"null"` и `"None"` с целевыми аннотациями `None` и `type(None)`. | Каждое из четырёх сочетаний возвращает именно singleton `None`. | +| `test_reject_invalid_none_value` | Зафиксировать точный allowlist и отсутствие неоговорённой нормализации. | Декартово параметризовать обе целевые аннотации с пустой строкой, `none`, `NULL`, `nil`, `" null "` и `" None "`. | Каждый вариант выбрасывает `TypeError` с соответствующим сообщением о невозможности интерпретации как `None`. | +| `test_get_list_value` (существующий тест) | Закрепить текущее поведение `None` в типизированных JSON-списках. | Использовать существующую параметризованную фикстуру типа списка; добавить `[null]` и `["None"]` с аннотацией элемента `None`. | `[null]` преобразуется в список с `None`, а `["None"]` отклоняется стандартным list-format `TypeError`. | +| `test_get_tuple_value` (существующий тест) | Закрепить текущее поведение для фиксированных и вариативных типизированных кортежей. | Использовать существующую параметризованную фикстуру типа кортежа; проверить JSON `null` и строку `"None"` для `Tuple[None]` и `Tuple[None, ...]`. | JSON `null` становится `None` в обоих видах кортежей, а строка `"None"` отклоняется стандартным tuple-format `TypeError`. | +| `test_get_dict_value` (существующий тест) | Закрепить текущее поведение `None` в значениях типизированных JSON-словарей. | Использовать существующую параметризованную фикстуру типа словаря; добавить объекты со значением `null` и строковым значением `"None"` при аннотации значения `None`. | JSON `null` становится значением `None`, а строка `"None"` отклоняется стандартным dict-format `TypeError`. | +| `test_none_deserialization_types` (тест типизации) | Проверить новый публичный контракт и отсутствие расширения типов существующих вызовов до `Optional`. | В typing-тесте присвоить результаты вызовов с `None` и `type(None)` переменным типа `None`, а результат вызова с `int` — переменной типа `int`. | `mypy` принимает все присваивания; runtime-значения соответствуют `None`, `None` и целому числу соответственно. | + +## README + +- В список поддерживаемых типов добавить `None` и `type(None)`, указав точные допустимые строки `"null"` и `"None"`. +- В большой пример добавить `from_string('null', None)` и `from_string('None', type(None))`; для обоих показать результат `None`. + +## Проверка качества + +- Запустить полный набор `pytest`, включая unit- и typing-тесты. +- Подтвердить 100% line coverage и branch coverage. +- Запустить `ruff` отдельно для библиотеки и тестов. +- Запустить `mypy --strict simtypes` и `mypy tests --exclude typing`. From 826c23e1781ce965e0fb17d34519a14a35c20385 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, 21 Aug 2026 02:53:38 +0300 Subject: [PATCH 2/5] Rename test_none_deserialization_types to test_none_deserialization_return_types --- docs/plans/1.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/1.md b/docs/plans/1.md index 27c0a2b..7905756 100644 --- a/docs/plans/1.md +++ b/docs/plans/1.md @@ -34,7 +34,7 @@ Runtime-тесты разместить в `tests/units/test_from_string.py`. П | `test_get_list_value` (существующий тест) | Закрепить текущее поведение `None` в типизированных JSON-списках. | Использовать существующую параметризованную фикстуру типа списка; добавить `[null]` и `["None"]` с аннотацией элемента `None`. | `[null]` преобразуется в список с `None`, а `["None"]` отклоняется стандартным list-format `TypeError`. | | `test_get_tuple_value` (существующий тест) | Закрепить текущее поведение для фиксированных и вариативных типизированных кортежей. | Использовать существующую параметризованную фикстуру типа кортежа; проверить JSON `null` и строку `"None"` для `Tuple[None]` и `Tuple[None, ...]`. | JSON `null` становится `None` в обоих видах кортежей, а строка `"None"` отклоняется стандартным tuple-format `TypeError`. | | `test_get_dict_value` (существующий тест) | Закрепить текущее поведение `None` в значениях типизированных JSON-словарей. | Использовать существующую параметризованную фикстуру типа словаря; добавить объекты со значением `null` и строковым значением `"None"` при аннотации значения `None`. | JSON `null` становится значением `None`, а строка `"None"` отклоняется стандартным dict-format `TypeError`. | -| `test_none_deserialization_types` (тест типизации) | Проверить новый публичный контракт и отсутствие расширения типов существующих вызовов до `Optional`. | В typing-тесте присвоить результаты вызовов с `None` и `type(None)` переменным типа `None`, а результат вызова с `int` — переменной типа `int`. | `mypy` принимает все присваивания; runtime-значения соответствуют `None`, `None` и целому числу соответственно. | +| `test_none_deserialization_return_types` (тест типизации) | Проверить новый публичный контракт и отсутствие расширения типов существующих вызовов до `Optional`. | В typing-тесте присвоить результаты вызовов с `None` и `type(None)` переменным типа `None`, а результат вызова с `int` — переменной типа `int`. | `mypy` принимает все присваивания; runtime-значения соответствуют `None`, `None` и целому числу соответственно. | ## README From 9367e71aaf59855628486b501104cdcb9fdc3ad9 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, 21 Aug 2026 02:58:48 +0300 Subject: [PATCH 3/5] Support None type in from_string conversion --- simtypes/from_string.py | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/simtypes/from_string.py b/simtypes/from_string.py index 0b81991..a23a4c3 100644 --- a/simtypes/from_string.py +++ b/simtypes/from_string.py @@ -2,13 +2,29 @@ from datetime import date, datetime from inspect import isclass from json import JSONDecodeError, loads -from typing import Any, Dict, List, Optional, Tuple, Type, Union, get_args, get_origin +from typing import ( + Any, + Dict, + List, + Optional, + Tuple, + Type, + Union, + get_args, + get_origin, + overload, +) from simtypes import check from simtypes.typing import ExpectedType def convert_single_value(value: str, expected_type: Type[ExpectedType]) -> ExpectedType: # noqa: PLR0912, PLR0911, C901 + if expected_type is type(None): + if value in ('null', 'None'): + return None + raise TypeError(f'The string "{value}" cannot be interpreted as None.') + if expected_type is str: return value # type: ignore[return-value] @@ -196,10 +212,23 @@ def fix_iterable_types(collection: Union[List[Any], Tuple[Any, ...], Dict[Hashab return result +@overload +def from_string(value: str, expected_type: None) -> None: + ... # pragma: no cover + + +@overload def from_string(value: str, expected_type: Type[ExpectedType]) -> ExpectedType: + ... # pragma: no cover + + +def from_string(value: str, expected_type: Optional[Type[ExpectedType]]) -> Optional[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 None: + return convert_single_value(value, type(None)) + if expected_type is Any: # type: ignore[comparison-overlap] return value # type: ignore[return-value] From e4f7ac9a02b71306900641958556cb5b7a79141b 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, 21 Aug 2026 02:59:02 +0300 Subject: [PATCH 4/5] Add None deserialization support to from_string --- tests/typing/test_from_string.py | 24 ++++++++++++++++ tests/units/test_from_string.py | 47 ++++++++++++++++++++++++++------ 2 files changed, 63 insertions(+), 8 deletions(-) create mode 100644 tests/typing/test_from_string.py diff --git a/tests/typing/test_from_string.py b/tests/typing/test_from_string.py new file mode 100644 index 0000000..2e56949 --- /dev/null +++ b/tests/typing/test_from_string.py @@ -0,0 +1,24 @@ +import pytest + +from simtypes import from_string + +try: + from typing import assert_type # type: ignore[attr-defined, unused-ignore] +except ImportError: # pragma: no cover + from typing_extensions import assert_type + + +@pytest.mark.mypy_testing +def test_none_deserialization_return_types() -> None: + """ + assert_type checks exact return types: None for None and type(None), and int for int. + + Runtime checks confirm the None singleton for both None targets and 1 for int. + """ + bare_none_result = assert_type(from_string('null', None), None) + none_type_result = assert_type(from_string('None', type(None)), None) + int_result = assert_type(from_string('1', int), int) + + assert bare_none_result is None + assert none_type_result is None + assert int_result == 1 diff --git a/tests/units/test_from_string.py b/tests/units/test_from_string.py index 4753488..ce06e5b 100644 --- a/tests/units/test_from_string.py +++ b/tests/units/test_from_string.py @@ -9,13 +9,11 @@ from simtypes import from_string -def test_value_is_not_string(): +@pytest.mark.parametrize('expected_type', [int, str, None, type(None)]) +def test_value_is_not_string(expected_type): """Reject non-string input with ValueError before deserialization, for both parsed and passthrough target types.""" with pytest.raises(ValueError, match=match('You can only pass a string as a string. You passed int.')): - from_string(5, int) - - with pytest.raises(ValueError, match=match('You can only pass a string as a string. You passed int.')): - from_string(5, str) + from_string(5, expected_type) def test_type_is_not_type(): @@ -36,10 +34,25 @@ class SuperType: from_string('kek', SuperType) -def test_get_string_value(): +@pytest.mark.parametrize('string', ['kek', 'lol', 'null', 'None']) +def test_get_string_value(string): """Explicit str deserialization returns valid string input unchanged.""" - assert from_string('kek', str) == 'kek' - assert from_string('lol', str) == 'lol' + assert from_string(string, str) == string + + +@pytest.mark.parametrize('expected_type', [None, type(None)]) +@pytest.mark.parametrize('string', ['null', 'None']) +def test_get_none_value(string, expected_type): + """The exact tokens "null" and "None" deserialize to the None singleton for None and type(None) targets.""" + assert from_string(string, expected_type) is None + + +@pytest.mark.parametrize('expected_type', [None, type(None)]) +@pytest.mark.parametrize('string', ['', 'none', 'NULL', 'nil', ' null', 'null ', ' None', 'None ', ' null ', ' None ']) +def test_reject_invalid_none_value(string, expected_type): + """None and type(None) targets reject empty, case-variant, unrelated, and whitespace-padded tokens with the exact TypeError message containing the original input.""" + with pytest.raises(TypeError, match=match(f'The string "{string}" cannot be interpreted as None.')): + from_string(string, expected_type) def test_get_int_value(): @@ -159,6 +172,7 @@ def test_get_list_value(list_type, subscribable_dict_type, subscribable_list_typ assert from_string('[1, 2, 3]', subscribable_list_type[int]) == [1, 2, 3] assert from_string('["lol", "kek"]', subscribable_list_type[str]) == ["lol", "kek"] + assert from_string('[null]', subscribable_list_type[None]) == [None] assert from_string('[["lol", "kek"], ["lol", "kek"]]', subscribable_list_type[subscribable_list_type[str]]) == [["lol", "kek"], ["lol", "kek"]] assert from_string('[{"lol": "kek"}, {"lol": "kek"}]', subscribable_list_type[subscribable_dict_type[str, str]]) == [{'lol': 'kek'}, {'lol': 'kek'}] @@ -178,6 +192,9 @@ def test_get_list_value(list_type, subscribable_dict_type, subscribable_list_typ with pytest.raises(TypeError, match=match('The string "[1, 2, "3"]" cannot be interpreted as a list of the specified format.')): from_string('[1, 2, "3"]', subscribable_list_type[str]) + with pytest.raises(TypeError, match=match('The string "["None"]" cannot be interpreted as a list of the specified format.')): + from_string('["None"]', subscribable_list_type[None]) + with pytest.raises(TypeError, match=match('The string "[1, 2, "3"" cannot be interpreted as a list of the specified format.')): from_string('[1, 2, "3"', subscribable_list_type[str]) @@ -212,6 +229,8 @@ def test_get_tuple_value(tuple_type, subscribable_tuple_type, subscribable_dict_ 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('[null]', subscribable_tuple_type[None]) == (None,) + assert from_string('[null, null]', subscribable_tuple_type[None, ...]) == (None, None) 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'}) @@ -240,6 +259,12 @@ def test_get_tuple_value(tuple_type, subscribable_tuple_type, subscribable_dict_ with pytest.raises(TypeError, match=match('The string "[1, 2, "3"]" cannot be interpreted as a tuple of the specified format.')): from_string('[1, 2, "3"]', subscribable_tuple_type[str]) + with pytest.raises(TypeError, match=match('The string "["None"]" cannot be interpreted as a tuple of the specified format.')): + from_string('["None"]', subscribable_tuple_type[None]) + + with pytest.raises(TypeError, match=match('The string "["None"]" cannot be interpreted as a tuple of the specified format.')): + from_string('["None"]', subscribable_tuple_type[None, ...]) + with pytest.raises(TypeError, match=match('The string "[1, 2, "3"" cannot be interpreted as a tuple of the specified format.')): from_string('[1, 2, "3"', subscribable_tuple_type[str]) @@ -275,6 +300,7 @@ def test_get_dict_value(dict_type, subscribable_list_type, subscribable_dict_typ assert from_string('{"1": 1, "2": 2, "3": 3}', subscribable_dict_type[str, int]) == {"1": 1, "2": 2, "3": 3} assert from_string('{"lol": "kek"}', subscribable_dict_type[str, str]) == {"lol": "kek"} assert from_string('{"lol": 1, "kek": 2}', subscribable_dict_type[str, int]) == {"lol": 1, "kek": 2} + assert from_string('{"none": null}', subscribable_dict_type[str, None]) == {'none': None} assert from_string('{"kek": ["lol", "kek"]}', subscribable_dict_type[str, subscribable_list_type[str]]) == {"kek": ["lol", "kek"]} assert from_string('{"123": [{"lol": "kek"}, {"lol": "kek"}]}', subscribable_dict_type[str, subscribable_list_type[subscribable_dict_type[str, str]]]) == {"123": [{"lol": "kek"}, {"lol": "kek"}]} @@ -325,6 +351,9 @@ def test_get_dict_value(dict_type, subscribable_list_type, subscribable_dict_typ with pytest.raises(TypeError, match=match('The string "{"lol": "kek"}" cannot be interpreted as a dict of the specified format.')): from_string('{"lol": "kek"}', subscribable_dict_type[int, int]) + with pytest.raises(TypeError, match=match('The string "{"none": "None"}" cannot be interpreted as a dict of the specified format.')): + from_string('{"none": "None"}', subscribable_dict_type[str, None]) + with pytest.raises(TypeError, match=match('The string "{"lol": ["kek"]}" cannot be interpreted as a dict of the specified format.')): from_string('{"lol": ["kek"]}', subscribable_dict_type[str, subscribable_list_type[int]]) @@ -338,6 +367,8 @@ def test_get_dict_value(dict_type, subscribable_list_type, subscribable_dict_typ '{"lol": "kek"}', '1', 'kek', + 'null', + 'None', ], ) def test_get_any(string): From f640f7bae03450d386a177021740fac744f719d5 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, 21 Aug 2026 02:59:13 +0300 Subject: [PATCH 5/5] Document None type deserialization --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 6ff0c11..a505927 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,7 @@ print(check(InnerNoneType('key'), InnerNoneType('key'))) The library also provides basic deserialization. Conversion of strings into several basic types in various combinations is supported: - `str` - any string can be interpreted as a `str` type. +- `None` or `type(None)` - the strings `"null"` and `"None"` are interpreted as `None`. - `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`. @@ -223,6 +224,12 @@ print(from_string('I am the danger', str)) print(from_string('I am the danger', Any)) # Any is interpreted as a string. #> "I am the danger" +# None +print(from_string('null', None)) +#> None +print(from_string('None', type(None))) +#> None + # bools print(from_string('yes', bool)) #> True