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 01/24] 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 02/24] 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 03/24] 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 04/24] 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 05/24] 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 From 47c6e0f62daa2f01394b027f32dc49f6b7239d65 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 03:00:18 +0300 Subject: [PATCH 06/24] Add preview image to docs --- docs/assets/preview.png | Bin 0 -> 110982 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/assets/preview.png diff --git a/docs/assets/preview.png b/docs/assets/preview.png new file mode 100644 index 0000000000000000000000000000000000000000..3db8f6e514e50ef2f5414343bb5e6a2062fc60fa GIT binary patch literal 110982 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⪙XURmY3=r8h2nF4wZfn-)(&tDs!_+2I}sKfb(tS` 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 ghJXLNthD3%2Os})uHoly Date: Fri, 21 Aug 2026 22:19:57 +0300 Subject: [PATCH 07/24] Add .hypothesis to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 263acc3..60c1009 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ __pycache__ .pytest_cache +.hypothesis .DS_Store test.py *.egg-info From 25791f235c269a49ff933713b1eab76ad36f8887 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 22:20:33 +0300 Subject: [PATCH 08/24] Add to_string serialization implementation plan --- docs/plans/2.md | 227 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 docs/plans/2.md diff --git a/docs/plans/2.md b/docs/plans/2.md new file mode 100644 index 0000000..db268f5 --- /dev/null +++ b/docs/plans/2.md @@ -0,0 +1,227 @@ +# Добавление `to_string` + +Библиотека уже поддерживает десериализацию строк с помощью функции `from_string`, но не поддерживает обратную операцию — сериализацию. Мы хотим добавить обратную операцию `to_string`. На этапе предварительного анализа мы отдельно проверили, нужно ли передавать в неё ожидаемый тип, и выяснили, что это необязательно: на этапе сериализации нет неоднозначностей, которые такой параметр мог бы устранить. + +Изначально мы хотели полностью покрыть все поддерживаемые типы и гарантировать для них round-trip через последовательный вызов `to_string` и `from_string`. Однако некоторые ограничения возникают из-за JSON-формата, используемого для сериализации коллекций. Например, числовые ключи допустимы в Python-словарях, но имена полей JSON-object должны быть строками. Поэтому ключ `1` пришлось бы преобразовать в `"1"`, изменив его тип и нарушив принцип round-trip. При этом сам `int` остаётся поддерживаемым типом: проблема возникает именно при его использовании в позиции ключа словаря. + +Чтобы такие преобразования не происходили незаметно, по умолчанию `to_string` будет запрещать сериализацию значений, которые в занимаемой ими позиции нельзя перевести в JSON без изменения типа. В таком случае будет выбрасываться `NonRoundTrippableKeyError`. Эту проверку можно явно отключить, передав `strict_json_dict=False`. Отключение проверки не добавляет поддержку произвольных типов, но позволяет сериализовать уже поддерживаемый и JSON-совместимый тип в потенциально lossy-позиции — например, `int` в качестве ключа словаря. Такая сериализация будет выполнена с необходимой конвертацией, поэтому round-trip для её результата не гарантируется. + +## Подготовка + +Перед началом работы сохранить этот план целиком в `docs/plans/2.md`. + +## Публичный API и контракт + +В `simtypes/to_string.py` добавить: + +```python +def to_string(value: Any, *, strict_json_dict: bool = True) -> str: + ... + + +class NonRoundTrippableKeyError(TypeError): + ... +``` + +Оба символа экспортировать из `simtypes/__init__.py`. + +Правила API: + +- `strict_json_dict` является keyword-only аргументом. +- Значение `strict_json_dict` должно иметь точный тип `bool`; иначе выбрасывается `TypeError` с сообщением `strict_json_dict must be a bool.` +- Поддерживаются только точные типы `str`, `int`, `float`, `bool`, `NoneType`, `date`, `datetime`, `list`, `tuple` и `dict`. +- Наследники поддерживаемых типов не считаются автоматически поддерживаемыми. +- Существующие реализацию и тесты `from_string` не изменять. +- Циклические структуры остаются вне области этой задачи; специальную проверку и тесты для них не добавлять. + +Round-trip формулируется как: + +```python +from_string(to_string(value), expected_type) ≈ value +``` + +Полный, включая generic-параметры, `expected_type` хранится вызывающим кодом и передаётся в `from_string`. + +Эквивалентность `≈` означает: + +- точные runtime-типы и значения совпадают рекурсивно; +- два значения `NaN` считаются эквивалентными; +- для нулевых `float` сохраняется знак; +- для `datetime` сохраняются календарные поля, время и микросекунды; +- UTC offset сохраняется, кроме ненулевых значений меньше одной секунды по модулю: `datetime.fromisoformat()` нормализует их в нулевой offset; +- identity объекта `tzinfo`, его имя и значение `fold` форматом ISO не сохраняются. + +При `expected_type=Any` round-trip гарантируется только тогда, когда исходное Python-значение имеет точный тип `str`. Для любого другого значения `from_string(..., Any)` возвращает сериализованный текст как строку, а не исходное типизированное значение. + +## Правила сериализации + +### Значения верхнего уровня + +| Тип | Представление | +|---|---| +| `str` | Исходная строка без JSON-кавычек | +| `int` | Стандартное строковое представление Python | +| `float` | Стандартное строковое представление Python, включая `nan`, `inf`, `-inf` и `-0.0` | +| `bool` | `True` или `False` | +| `NoneType` | `None` | +| `date` | `date.isoformat()` | +| `datetime` | `datetime.isoformat()` | +| `list`, `tuple`, `dict` | JSON-представление | + +### Значения внутри коллекций + +- Строки кодируются как JSON strings с обычным экранированием. +- `None` преобразуется в `null`. +- `bool` преобразуется в `true` или `false`. +- `date` и `datetime` преобразуются в JSON strings с ISO-представлением. +- `list` и `tuple` преобразуются в JSON arrays. +- Словари преобразуются в JSON objects с сохранением порядка элементов. +- Для полностью JSON-native коллекций результат должен совпадать с `json.dumps` с его стандартными настройками, включая пробелы, экранирование Unicode и представление специальных `float`. + +### Ключи словарей + +При `strict_json_dict=True`: + +- разрешены только ключи точного типа `str`; +- поддерживаемые, но меняющие тип в JSON ключи `int`, `float`, `bool`, `NoneType`, `date` и `datetime` приводят к `NonRoundTrippableKeyError`; +- проверка применяется рекурсивно ко всем вложенным словарям. + +Сообщение исключения: + +```text +Dictionary key {key!r} of type {type_name} cannot be serialized without changing its type. Pass strict_json_dict=False to allow lossy serialization. +``` + +При `strict_json_dict=False`: + +- разрешены точные типы ключей `str`, `int`, `float`, `bool`, `NoneType`, `date` и `datetime`; +- числовые, boolean и `None`-ключи преобразуются в те же имена JSON-свойств, которые создаёт `json.dumps`; +- `date` и `datetime` преобразуются через `isoformat()`; +- канонические коллизии не отклоняются: сериализатор сохраняет порядок элементов и может создать несколько одинаковых JSON property names; +- словари кодируются поэлементно, без предварительного помещения нормализованных ключей в новый Python-словарь, чтобы коллизии не терялись до формирования JSON. + +Ключи неподдерживаемых типов, включая `tuple` и пользовательские hashable-объекты, в обоих режимах приводят к обычному `TypeError`: + +```text +Dictionary key {key!r} of type {type_name} cannot be serialized to JSON. +``` + +### Неподдерживаемые значения + +Для неподдерживаемого top-level или вложенного значения выбрасывается `TypeError`: + +```text +Serialization of the type {type_name} is not supported. Supported types: str, int, float, bool, NoneType, date, datetime, list, tuple, dict. +``` + +Реализацию построить как рекурсивный сериализатор с разными правилами для top-level scalar и значения внутри JSON-коллекции. Проверки типов выполнять по точному `type`, чтобы корректно различать `bool`/`int` и `datetime`/`date`. + +## Тестовая инфраструктура + +Работать последовательно по TDD: сначала добавить зависимость и все новые тесты, зафиксировать красный прогон, затем добавлять production-код. + +В `requirements_dev.txt` добавить единственное ограничение: + +```text +hypothesis>=6.113.0 +``` + +`6.113.0` — последний релиз с поддержкой Python 3.8; начиная с `6.114.0` поддержка Python 3.8 удалена. Благодаря `Requires-Python` установщик на Python 3.8 выберет последний совместимый релиз, а на новых версиях Python сможет установить более новый. Это подтверждено [официальным changelog Hypothesis](https://hypothesis.readthedocs.io/en/latest/changelog.html#v6-114-0). + +Если тест использует Hypothesis, это явно указывается в колонке «Подготовка» вместе с названием стратегии и настройками генерации. Для остальных тестов используется детерминированная подготовка и `pytest.mark.parametrize`, когда требуется несколько сценариев. + +В `tests/units/test_to_string.py` определить: + +- `_assert_round_trip_equivalent(expected, actual)` — рекурсивно проверяет точные типы, `NaN`, знак нуля и установленную семантику `datetime`; +- `round_trippable_scalar_cases` — пары из значения и соответствующего точного типа для всех scalar-категорий; `datetime` с ненулевым UTC offset меньше одной секунды по модулю исключаются и проверяются отдельным тестом ограничения; +- `recursive_round_trip_cases` — ограниченные по размеру ациклические деревья из списков, кортежей и словарей вместе с точной параметризованной аннотацией; +- `string_key_dict_cases` — словари только со строковыми ключами и соответствующим точным типом; +- `json_native_collection_cases` — JSON-native коллекции для сравнения с `json.dumps`. + +Новые fixtures не добавлять. Для проверки `typing.List`/`list`, `typing.Tuple`/`tuple` и `typing.Dict`/`dict` использовать существующие generic-alias fixtures проекта. + +## Таблица тестов + +Все перечисленные ниже тесты новые. + +### Формат сериализации + +| Название теста | Суть фиксируемого поведения | Цель | Подготовка | Проверки | +|---|---|---|---|---| +| `test_serialize_scalar` | Каждый scalar получает установленное top-level представление | Зафиксировать формат для `str`, `int`, `float`, `bool`, `None`, `date` и `datetime` | Детерминированная параметризация: пустая и Unicode-строка, положительные и отрицательные числа, `-0.0`, `nan`, infinities, оба boolean, `None`, дата, naive и aware datetime | Результат имеет точный тип `str` и полностью совпадает с ожидаемым текстом | +| `test_serialize_json_native_collection` | JSON-native списки, кортежи и словари сериализуются в JSON | Проверить форму массивов, объектов, вложенность, порядок и экранирование | Детерминированная параметризация вложенных коллекций, Unicode, кавычек и управляющих символов | Результат полностью совпадает с ожидаемой JSON-строкой | +| `test_serialize_nested_temporal_values` | Вложенные `date` и `datetime` становятся JSON strings с ISO-значениями | Проверить контекстное отличие temporal-значений от top-level формы | Детерминированная параметризация temporal-значений в списке, кортеже и значениях словаря | ISO-значения заключены в JSON-кавычки и корректно расположены во вложенной структуре | +| `test_serialize_none_inside_collection` | Вложенный `None` становится `null`, хотя top-level `None` становится `None` | Зафиксировать контекстное преобразование | Детерминированная параметризация `None` в списках, кортежах, значениях словарей и вложенных комбинациях | В JSON присутствует `null`; top-level и nested представления различаются ожидаемым образом | +| `test_json_native_output_matches_json_dumps` | Для JSON-native коллекций формат идентичен стандартному encoder | Не допустить расхождений в пробелах, escaping и специальных числах | Hypothesis: `json_native_collection_cases`; `max_examples=200`, `deadline=None` | `to_string(value)` полностью равен `json.dumps(value)` | + +### Политика ключей словарей + +| Название теста | Суть фиксируемого поведения | Цель | Подготовка | Проверки | +|---|---|---|---|---| +| `test_strict_json_dict_accepts_only_string_keys` | Строгий режим принимает пустые словари и словари со строковыми ключами | Зафиксировать разрешённый strict-домен | Детерминированная параметризация простых и вложенных string-key словарей | Получается ожидаемый JSON без исключений | +| `test_strict_json_dict_rejects_non_string_keys` | Поддерживаемые значения других типов запрещены в позиции ключа | Проверить защиту от незаметного изменения типа | Детерминированная параметризация ключей точных типов `int`, `float`, `bool`, `NoneType`, `date`, `datetime` | Выбрасывается `NonRoundTrippableKeyError`, являющийся `TypeError`; сообщение содержит repr ключа, имя типа и способ отключить проверку | +| `test_strict_json_dict_applies_recursively` | Политика проверяет вложенные словари | Не допустить обхода ограничения через nesting | Детерминированные структуры с допустимым внешним словарём и запрещённым ключом на разных уровнях | Для каждого уровня выбрасывается `NonRoundTrippableKeyError` с данными фактического ключа | +| `test_non_strict_json_dict_accepts_lossy_keys` | Отключение проверки разрешает поддерживаемые non-string ключи | Зафиксировать точные канонические JSON property names | Детерминированная параметризация `int`, `float`, `bool`, `None`, `date`, `datetime`, включая вложенные словари | Результат содержит ожидаемые строковые имена JSON-свойств | +| `test_non_strict_json_dict_allows_canonical_collisions` | Разные Python-ключи могут породить одинаковое JSON-имя | Проверить, что элемент не теряется до формирования JSON | Словари с парами `date`/ISO-строка и `datetime`/ISO-строка, сохраняющимися как разные Python-ключи | В выходной строке присутствуют оба свойства в исходном порядке, даже если их JSON-имена совпадают | +| `test_non_strict_json_dict_breaks_round_trip` | Разрешённый числовой ключ после десериализации не восстанавливает исходный тип | Наглядно зафиксировать последствие отключения защиты | Словарь с `int`-ключом сериализуется с `strict_json_dict=False`, затем разбирается с совместимым принимаемым типом | Сериализация успешна, но полученный ключ является строкой и итоговый словарь не эквивалентен исходному | +| `test_non_strict_json_dict_rejects_unserializable_keys` | Отключение защиты не добавляет поддержку произвольных ключей | Отделить lossy-поддержку от unsupported типов | Детерминированная параметризация tuple-ключа, пользовательского hashable-класса и наследника строки; проверить оба режима там, где применимо | Выбрасывается обычный `TypeError`, но не `NonRoundTrippableKeyError`; сообщение соответствует контракту unsupported key | +| `test_strict_json_dict_is_keyword_only` | Флаг нельзя передать позиционно | Зафиксировать сигнатуру API | Детерминированный вызов с двумя позиционными аргументами | Выбрасывается `TypeError` до сериализации | +| `test_strict_json_dict_must_be_bool` | Флаг принимает только точный `bool` | Не допустить неявного принятия `0`, `1`, `None` или строки | Детерминированная параметризация невалидных значений флага на простом scalar и словаре | Для каждого значения выбрасывается `TypeError` с точным сообщением `strict_json_dict must be a bool.` | + +### Ошибки и публичный экспорт + +| Название теста | Суть фиксируемого поведения | Цель | Подготовка | Проверки | +|---|---|---|---|---| +| `test_to_string_rejects_unsupported_top_level_value` | Неподдерживаемые top-level типы отклоняются | Зафиксировать закрытый список типов | Детерминированная параметризация `set`, bytes, произвольного объекта и наследников поддерживаемых типов | Выбрасывается `TypeError` с точным сообщением и полным списком поддерживаемых типов | +| `test_to_string_rejects_nested_unsupported_type` | Та же проверка работает рекурсивно | Исключить частичную или неявную сериализацию вложенных объектов | Детерминированные list, tuple и string-key dict с неподдерживаемым значением на разных уровнях | Выбрасывается `TypeError`, сообщение указывает фактический вложенный тип | +| `test_public_to_string_api` | Функция и exception доступны из публичного пакета | Зафиксировать внешний API | Прямой импорт обоих символов из `simtypes` | Импорт успешен; функция вызываема; exception является наследником `TypeError` | + +### Round-trip + +| Название теста | Суть фиксируемого поведения | Цель | Подготовка | Проверки | +|---|---|---|---|---| +| `test_round_trippable_scalar_values_serialize_canonically_and_round_trip` | Все scalar-категории в round-trippable-домене проходят сериализацию и обратную десериализацию | Проверить основной инвариант на широком диапазоне значений | Hypothesis: `round_trippable_scalar_cases`; `max_examples=200`, `deadline=None`. Стратегия исключает `datetime` с ненулевым UTC offset меньше одной секунды по модулю. Добавить явные examples для `None` с `None` и `type(None)`, `-0.0`, infinities, `NaN`, aware datetime и `fold=1` | `_assert_round_trip_equivalent` подтверждает значение, точный тип, семантику `NaN`, знак нуля и установленную семантику datetime | +| `test_datetime_round_trip_preserves_fields_and_offset_but_loses_tzinfo_identity_name_and_fold` | ISO round-trip сохраняет дату, время, микросекунды и offset, но теряет identity и имя `tzinfo`, а также `fold` | Явно покрыть заявленное ограничение datetime отдельным тестом | Детерминированный aware datetime с именованным fixed-offset `tzinfo`, микросекундами и `fold=1`; сериализация и `from_string(..., datetime)` | Календарные и временные поля и `utcoffset()` совпадают; точный тип остаётся `datetime`; восстановленный `tzinfo` не является исходным объектом и теряет исходное имя; `fold` восстановленного значения равен `0` | +| `test_datetime_serialization_preserves_subsecond_offset_but_round_trip_loses_it` | ISO-текст сохраняет ненулевой UTC offset меньше секунды по модулю, но `from_string` нормализует его в нулевой offset | Явно зафиксировать границу datetime round-trip-гарантии | Детерминированная параметризация положительных и отрицательных subsecond-offsets; сериализация и `from_string(..., datetime)` | Сериализованный текст совпадает с `datetime.isoformat()`, календарные и временные поля сохраняются, исходный subsecond-offset после десериализации становится нулевым | +| `test_recursive_collection_round_trip` | Поддерживаемые вложенные list/tuple/dict восстанавливают структуру и точные типы | Проверить рекурсивный инвариант | Hypothesis: `recursive_round_trip_cases`; `max_examples=200`, `deadline=None` | `_assert_round_trip_equivalent` подтверждает структуру, значения и exact-типы на всех уровнях | +| `test_string_dict_key_round_trip` | Строгие словари со строковыми ключами восстанавливаются без потерь | Проверить полный безопасный домен словарей | Hypothesis: `string_key_dict_cases`; `max_examples=200`, `deadline=None` | Ключи остаются точными строками, значения и вложенные структуры эквивалентны исходным | +| `test_round_trip_requires_precise_external_type` | Одинаковый JSON может означать разные вложенные типы | Зафиксировать необходимость хранить полный тип для `from_string`, но не передавать его в `to_string` | Детерминированная параметризация `list[date]`, `list[tuple[int, ...]]` и вложенных tuple/list с точной и стёртой аннотациями | С точной аннотацией исходное значение восстанавливается; с bare-контейнером вложенный тип меняется | +| `test_any_round_trip_succeeds_only_when_original_value_is_str` | `Any` возвращает входной сериализованный текст, поэтому исходное значение сохраняется только для Python-строки | Зафиксировать фактическое поведение `Any` без формулировки про разные виды строк | Детерминированная параметризация строки и нескольких поддерживаемых нестроковых scalar-значений | Для точного `str` результат равен исходному значению и имеет тип `str`; для остальных значений результат равен сериализованному тексту, имеет тип `str` и не эквивалентен исходному типизированному значению | +| `test_round_trip_with_typing_and_builtin_annotations` | Round-trip работает с обеими формами generic-аннотаций | Сохранить совместимость поддерживаемых версий Python и существующего API `from_string` | Детерминированная параметризация через существующие fixtures для `typing.List`/`list`, `typing.Tuple`/`tuple`, `typing.Dict`/`dict` | Каждая доступная форма аннотации восстанавливает исходное значение и точные вложенные типы | + +### Тест типизации + +| Название теста | Суть фиксируемого поведения | Цель | Подготовка | Проверки | +|---|---|---|---|---| +| `test_to_string_return_types` (тест типизации) | Публичная функция статически возвращает `str`, а boolean keyword принимается сигнатурой | Зафиксировать типизированный контракт API | Создать `tests/typing/test_to_string.py` с `pytest.mark.mypy_testing` и принятым в проекте fallback для `assert_type`. Сделать отдельные явные вызовы для `str`, `int`, `float`, `bool`, `None`, `date`, `datetime`, `list`, `tuple`, string-key dict и dict с `strict_json_dict=False`. Не использовать параметризацию или Hypothesis, чтобы mypy видел конкретные выражения | Каждый вызов проходит `assert_type(..., str)`; runtime-часть подтверждает, что каждый результат действительно является строкой | + +## Документация + +В README добавить пункт оглавления и раздел `String serialization` сразу после раздела десериализации. Раздел должен включать: + +- импорт и полную сигнатуру `to_string`; +- список поддерживаемых точных типов; +- top-level формы scalar-значений, включая `None`; +- JSON-форму коллекций и контекстное преобразование вложенных `None`, `date` и `datetime`; +- примеры round-trip для scalar, параметризованных list/tuple, вложенных temporal-значений и string-key dict; +- пояснение, что полный `expected_type` сохраняется вызывающим кодом для последующего `from_string`; +- пояснение поведения `Any`: исходное значение сохраняется только тогда, когда оно уже имело тип `str`; +- strict-пример с `NonRoundTrippableKeyError`; +- пример `strict_json_dict=False`, в котором числовой ключ после десериализации становится строкой; +- ограничение `NaN` и используемое для него семантическое сравнение; +- явное ограничение datetime: ISO-представление сохраняет календарные и временные поля; UTC offset сохраняется, кроме ненулевых значений меньше одной секунды по модулю, которые `datetime.fromisoformat()` нормализует в нулевой offset; identity/имя `tzinfo` и `fold` не сохраняются; +- возможность появления одинаковых JSON property names в non-strict режиме; +- поведение для неподдерживаемых типов и наследников поддерживаемых типов. + +## Последовательность реализации и приёмка + +1. Добавить `hypothesis>=6.113.0` в dev-зависимости. +2. Добавить helper, стратегии, runtime-тесты и тест типизации до production-кода. +3. Запустить новые тесты и зафиксировать ожидаемый красный результат из-за отсутствующего API. +4. Добавить модуль сериализации, exception и публичные экспорты, не изменяя `from_string`. +5. Добиться зелёного результата новых тестов и выполнить рефакторинг без изменения контракта. +6. Обновить README. +7. Запустить весь `pytest`, включая существующие unit- и typing-тесты. +8. Выполнить принятые в CI проверки line и branch coverage с порогом 100%. +9. Запустить `ruff check simtypes`, `ruff check tests`, `mypy --strict simtypes` и `mypy tests --exclude typing`. +10. Результат считается готовым, когда новые и существующие тесты, typing-проверки, линтер и обе проверки покрытия проходят успешно. From 201f25d3926d17141634c3040b67193194ef51be 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: Sat, 22 Aug 2026 21:03:39 +0300 Subject: [PATCH 09/24] Add hypothesis to dev requirements --- requirements_dev.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements_dev.txt b/requirements_dev.txt index 6105320..ce78c83 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,4 +1,5 @@ pytest==8.0.2 +hypothesis>=6.113.0 coverage==7.6.1 build==1.2.2.post1 twine==6.1.0 From dd15bc862b47a5a1e56ee3bf352e04acfb685fb3 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: Sun, 23 Aug 2026 01:18:01 +0300 Subject: [PATCH 10/24] Update serializer plan to rely on json.dumps for key handling --- docs/plans/2.md | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/docs/plans/2.md b/docs/plans/2.md index db268f5..238d5f5 100644 --- a/docs/plans/2.md +++ b/docs/plans/2.md @@ -17,8 +17,11 @@ ```python def to_string(value: Any, *, strict_json_dict: bool = True) -> str: ... +``` +В `simtypes/errors.py` добавить: +```python class NonRoundTrippableKeyError(TypeError): ... ``` @@ -83,7 +86,7 @@ from_string(to_string(value), expected_type) ≈ value При `strict_json_dict=True`: - разрешены только ключи точного типа `str`; -- поддерживаемые, но меняющие тип в JSON ключи `int`, `float`, `bool`, `NoneType`, `date` и `datetime` приводят к `NonRoundTrippableKeyError`; +- нативно поддерживаемые JSON, но меняющие тип ключи `int`, `float`, `bool` и `NoneType` приводят к `NonRoundTrippableKeyError`; - проверка применяется рекурсивно ко всем вложенным словарям. Сообщение исключения: @@ -94,13 +97,13 @@ Dictionary key {key!r} of type {type_name} cannot be serialized without changing При `strict_json_dict=False`: -- разрешены точные типы ключей `str`, `int`, `float`, `bool`, `NoneType`, `date` и `datetime`; +- разрешены точные типы ключей `str`, `int`, `float`, `bool` и `NoneType`; - числовые, boolean и `None`-ключи преобразуются в те же имена JSON-свойств, которые создаёт `json.dumps`; -- `date` и `datetime` преобразуются через `isoformat()`; +- `date` и `datetime` не разрешаются в позиции ключа: non-strict режим не расширяет набор ключей за пределы типов, которые `json.dumps` поддерживает нативно; - канонические коллизии не отклоняются: сериализатор сохраняет порядок элементов и может создать несколько одинаковых JSON property names; -- словари кодируются поэлементно, без предварительного помещения нормализованных ключей в новый Python-словарь, чтобы коллизии не терялись до формирования JSON. +- после рекурсивной проверки типов коллекция целиком кодируется через `json.dumps`, который сохраняет порядок и канонические коллизии нативно поддерживаемых ключей. -Ключи неподдерживаемых типов, включая `tuple` и пользовательские hashable-объекты, в обоих режимах приводят к обычному `TypeError`: +Ключи неподдерживаемых типов, включая `date`, `datetime`, `tuple` и пользовательские hashable-объекты, в обоих режимах приводят к обычному `TypeError`: ```text Dictionary key {key!r} of type {type_name} cannot be serialized to JSON. @@ -114,7 +117,7 @@ Dictionary key {key!r} of type {type_name} cannot be serialized to JSON. Serialization of the type {type_name} is not supported. Supported types: str, int, float, bool, NoneType, date, datetime, list, tuple, dict. ``` -Реализацию построить как рекурсивный сериализатор с разными правилами для top-level scalar и значения внутри JSON-коллекции. Проверки типов выполнять по точному `type`, чтобы корректно различать `bool`/`int` и `datetime`/`date`. +Реализацию построить как отдельную обработку top-level scalar и рекурсивную валидацию значений коллекции перед единым вызовом `json.dumps`. Вложенные `date` и `datetime` передавать стандартному encoder через `default`, возвращающий `isoformat()`. Проверки типов выполнять по точному `type`, чтобы корректно различать `bool`/`int` и `datetime`/`date` и не пропускать наследников поддерживаемых типов. ## Тестовая инфраструктура @@ -159,12 +162,12 @@ hypothesis>=6.113.0 | Название теста | Суть фиксируемого поведения | Цель | Подготовка | Проверки | |---|---|---|---|---| | `test_strict_json_dict_accepts_only_string_keys` | Строгий режим принимает пустые словари и словари со строковыми ключами | Зафиксировать разрешённый strict-домен | Детерминированная параметризация простых и вложенных string-key словарей | Получается ожидаемый JSON без исключений | -| `test_strict_json_dict_rejects_non_string_keys` | Поддерживаемые значения других типов запрещены в позиции ключа | Проверить защиту от незаметного изменения типа | Детерминированная параметризация ключей точных типов `int`, `float`, `bool`, `NoneType`, `date`, `datetime` | Выбрасывается `NonRoundTrippableKeyError`, являющийся `TypeError`; сообщение содержит repr ключа, имя типа и способ отключить проверку | +| `test_strict_json_dict_rejects_non_string_keys` | Нативно поддерживаемые JSON значения других типов запрещены в позиции ключа строгого словаря | Проверить защиту от незаметного изменения типа | Детерминированная параметризация ключей точных типов `int`, `float`, `bool` и `NoneType` | Выбрасывается `NonRoundTrippableKeyError`, являющийся `TypeError`; сообщение содержит repr ключа, имя типа и способ отключить проверку | | `test_strict_json_dict_applies_recursively` | Политика проверяет вложенные словари | Не допустить обхода ограничения через nesting | Детерминированные структуры с допустимым внешним словарём и запрещённым ключом на разных уровнях | Для каждого уровня выбрасывается `NonRoundTrippableKeyError` с данными фактического ключа | -| `test_non_strict_json_dict_accepts_lossy_keys` | Отключение проверки разрешает поддерживаемые non-string ключи | Зафиксировать точные канонические JSON property names | Детерминированная параметризация `int`, `float`, `bool`, `None`, `date`, `datetime`, включая вложенные словари | Результат содержит ожидаемые строковые имена JSON-свойств | -| `test_non_strict_json_dict_allows_canonical_collisions` | Разные Python-ключи могут породить одинаковое JSON-имя | Проверить, что элемент не теряется до формирования JSON | Словари с парами `date`/ISO-строка и `datetime`/ISO-строка, сохраняющимися как разные Python-ключи | В выходной строке присутствуют оба свойства в исходном порядке, даже если их JSON-имена совпадают | +| `test_non_strict_json_dict_accepts_lossy_keys` | Отключение проверки разрешает нативно поддерживаемые JSON non-string ключи | Зафиксировать точные канонические JSON property names | Детерминированная параметризация `int`, `float`, `bool` и `None`, включая вложенные словари | Результат содержит ожидаемые строковые имена JSON-свойств | +| `test_non_strict_json_dict_allows_canonical_collisions` | Разные нативно поддерживаемые Python-ключи могут породить одинаковое JSON-имя | Проверить, что `json.dumps` сохраняет оба элемента и их порядок | Словари с парами числовых, boolean или `None`-ключей и их канонических строковых форм, включая вложенный словарь | В выходной строке присутствуют оба свойства в исходном порядке, даже если их JSON-имена совпадают | | `test_non_strict_json_dict_breaks_round_trip` | Разрешённый числовой ключ после десериализации не восстанавливает исходный тип | Наглядно зафиксировать последствие отключения защиты | Словарь с `int`-ключом сериализуется с `strict_json_dict=False`, затем разбирается с совместимым принимаемым типом | Сериализация успешна, но полученный ключ является строкой и итоговый словарь не эквивалентен исходному | -| `test_non_strict_json_dict_rejects_unserializable_keys` | Отключение защиты не добавляет поддержку произвольных ключей | Отделить lossy-поддержку от unsupported типов | Детерминированная параметризация tuple-ключа, пользовательского hashable-класса и наследника строки; проверить оба режима там, где применимо | Выбрасывается обычный `TypeError`, но не `NonRoundTrippableKeyError`; сообщение соответствует контракту unsupported key | +| `test_non_strict_json_dict_rejects_unserializable_keys` | Отключение защиты не добавляет поддержку произвольных ключей | Отделить нативную lossy-поддержку от unsupported типов | Детерминированная параметризация точных `date` и `datetime`, tuple-ключа, пользовательского hashable-класса и наследников поддерживаемых типов; проверить оба режима и вложенные словари | Выбрасывается обычный `TypeError`, но не `NonRoundTrippableKeyError`; сообщение соответствует контракту unsupported key | | `test_strict_json_dict_is_keyword_only` | Флаг нельзя передать позиционно | Зафиксировать сигнатуру API | Детерминированный вызов с двумя позиционными аргументами | Выбрасывается `TypeError` до сериализации | | `test_strict_json_dict_must_be_bool` | Флаг принимает только точный `bool` | Не допустить неявного принятия `0`, `1`, `None` или строки | Детерминированная параметризация невалидных значений флага на простом scalar и словаре | Для каждого значения выбрасывается `TypeError` с точным сообщением `strict_json_dict must be a bool.` | @@ -208,6 +211,7 @@ hypothesis>=6.113.0 - пояснение поведения `Any`: исходное значение сохраняется только тогда, когда оно уже имело тип `str`; - strict-пример с `NonRoundTrippableKeyError`; - пример `strict_json_dict=False`, в котором числовой ключ после десериализации становится строкой; +- пояснение, что non-strict режим разрешает только нативно поддерживаемые `json.dumps` ключи `int`, `float`, `bool` и `None`, а `date` и `datetime` остаются неподдерживаемыми ключами; - ограничение `NaN` и используемое для него семантическое сравнение; - явное ограничение datetime: ISO-представление сохраняет календарные и временные поля; UTC offset сохраняется, кроме ненулевых значений меньше одной секунды по модулю, которые `datetime.fromisoformat()` нормализует в нулевой offset; identity/имя `tzinfo` и `fold` не сохраняются; - возможность появления одинаковых JSON property names в non-strict режиме; From 8c925d05a94c432c300240bf8c62969e1f7c9cda 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: Sun, 23 Aug 2026 01:18:16 +0300 Subject: [PATCH 11/24] Export NonRoundTrippableKeyError and to_string from simtypes --- simtypes/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/simtypes/__init__.py b/simtypes/__init__.py index ff84386..44737ea 100644 --- a/simtypes/__init__.py +++ b/simtypes/__init__.py @@ -1,5 +1,9 @@ from simtypes.check import check as check +from simtypes.errors import ( + NonRoundTrippableKeyError as NonRoundTrippableKeyError, +) from simtypes.from_string import from_string as from_string +from simtypes.to_string import to_string as to_string from simtypes.types.ints.natural import NaturalNumber as NaturalNumber from simtypes.types.ints.non_negative import ( NonNegativeInt as NonNegativeInt, From 30efdf987567e61659e7a068f5ba1c325d427d3f 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: Sun, 23 Aug 2026 01:18:25 +0300 Subject: [PATCH 12/24] Add NonRoundTrippableKeyError exception type --- simtypes/errors.py | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 simtypes/errors.py diff --git a/simtypes/errors.py b/simtypes/errors.py new file mode 100644 index 0000000..ad1e68a --- /dev/null +++ b/simtypes/errors.py @@ -0,0 +1,2 @@ +class NonRoundTrippableKeyError(TypeError): + ... From 1ebc09a291dd4e4400e7a4ed091ef7919e8df20a 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: Sun, 23 Aug 2026 18:11:11 +0300 Subject: [PATCH 13/24] Add AGENTS.md to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 60c1009..37d142f 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ uv.lock .ropeproject node_modules mutants +AGENTS.md From 5ed7add947b006a756875a6abb4e4c68f7adfb57 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, 24 Aug 2026 17:04:47 +0300 Subject: [PATCH 14/24] Add to_string utility for simtypes serialization --- simtypes/to_string.py | 61 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 simtypes/to_string.py diff --git a/simtypes/to_string.py b/simtypes/to_string.py new file mode 100644 index 0000000..8d4f73e --- /dev/null +++ b/simtypes/to_string.py @@ -0,0 +1,61 @@ +from datetime import date, datetime +from json import dumps +from typing import Any, NoReturn + +from simtypes.errors import NonRoundTrippableKeyError + +JSON_DUMPS_KEY_TYPES = (str, int, float, bool, type(None)) +SUPPORTED_SCALAR_TYPES = (*JSON_DUMPS_KEY_TYPES, date, datetime) + + +def raise_type_error_for_unsupported_value(value: Any) -> NoReturn: + raise TypeError( + f'Serialization of the type {type(value).__name__} is not supported. ' + 'Supported types: str, int, float, bool, NoneType, date, datetime, list, tuple, dict.', + ) + + +def check_collection(value: Any, strict_json_dict: bool) -> None: + value_type = type(value) + if any(value_type is scalar_type for scalar_type in SUPPORTED_SCALAR_TYPES): + return + if value_type is list or value_type is tuple: + for element in value: + check_collection(element, strict_json_dict) + return + if value_type is not dict: + raise_type_error_for_unsupported_value(value) + + for key, element in value.items(): + key_type = type(key) + if key_type is not str: + if not any(key_type is json_key_type for json_key_type in JSON_DUMPS_KEY_TYPES): + raise TypeError( + f'Dictionary key {key!r} of type {key_type.__name__} cannot be serialized to JSON.', + ) + if strict_json_dict: + raise NonRoundTrippableKeyError( + f'Dictionary key {key!r} of type {key_type.__name__} cannot be serialized without changing ' + 'its type. Pass strict_json_dict=False to allow lossy serialization.', + ) + check_collection(element, strict_json_dict) + + +def to_string(value: Any, *, strict_json_dict: bool = True) -> str: + if type(strict_json_dict) is not bool: + raise TypeError('strict_json_dict must be a bool.') + + value_type = type(value) + + if value_type is str or value_type is int or value_type is float or value_type is bool: + return str(value) + if value_type is type(None): + return 'None' + if value_type is date or value_type is datetime: + return str(value.isoformat()) + + if value_type is list or value_type is tuple or value_type is dict: + check_collection(value, strict_json_dict) + return dumps(value, default=lambda temporal_value: temporal_value.isoformat()) + + raise_type_error_for_unsupported_value(value) From 1f7da5b9c937535c528f3aa95f0fc84983662854 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, 24 Aug 2026 18:00:26 +0300 Subject: [PATCH 15/24] Remove redundant `-> None` annotations from test functions --- tests/typing/test_check.py | 6 +++--- tests/typing/test_from_string.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/typing/test_check.py b/tests/typing/test_check.py index fad618d..416ed73 100644 --- a/tests/typing/test_check.py +++ b/tests/typing/test_check.py @@ -4,14 +4,14 @@ @pytest.mark.mypy_testing -def test_basic_positives() -> None: +def test_basic_positives(): """Static typing accepts simple matching int and str check calls, and the runtime results are true.""" assert check(5, int) assert check("kek", str) @pytest.mark.mypy_testing -def test_positive_with_users_class() -> None: +def test_positive_with_users_class(): """Static typing accepts a locally defined class hint, and the runtime check is true for its instance.""" class SomeClass: pass @@ -20,7 +20,7 @@ class SomeClass: @pytest.mark.mypy_testing -def test_negative_with_users_class() -> None: +def test_negative_with_users_class(): """ A non-instance value can be passed with a local class hint without a static typing error. diff --git a/tests/typing/test_from_string.py b/tests/typing/test_from_string.py index 2e56949..2d56a68 100644 --- a/tests/typing/test_from_string.py +++ b/tests/typing/test_from_string.py @@ -9,7 +9,7 @@ @pytest.mark.mypy_testing -def test_none_deserialization_return_types() -> None: +def test_none_deserialization_return_types(): """ assert_type checks exact return types: None for None and type(None), and int for int. From 23ad6deb97dedde312afb2b9c50e6db25abc805d 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, 24 Aug 2026 20:05:40 +0300 Subject: [PATCH 16/24] Add to_string tests --- tests/units/test_to_string.py | 1260 +++++++++++++++++++++++++++++++++ 1 file changed, 1260 insertions(+) create mode 100644 tests/units/test_to_string.py diff --git a/tests/units/test_to_string.py b/tests/units/test_to_string.py new file mode 100644 index 0000000..2cf36f8 --- /dev/null +++ b/tests/units/test_to_string.py @@ -0,0 +1,1260 @@ +import json +import math +from datetime import date, datetime, timedelta, timezone +from typing import Any, Dict, List, Tuple, Type, Union + +import pytest +from full_match import match +from hypothesis import example, given, settings, strategies + +from simtypes import NonRoundTrippableKeyError, from_string, to_string + + +@pytest.mark.parametrize( + ('value', 'expected'), + [ + pytest.param('', '', id='empty-string'), + pytest.param('Hello, ☃!', 'Hello, ☃!', id='unicode-string'), + pytest.param('\ud800', '\ud800', id='high-surrogate-string'), + pytest.param('\udfff', '\udfff', id='low-surrogate-string'), + pytest.param(0, '0', id='zero-int'), + pytest.param(-13, '-13', id='negative-int'), + pytest.param(13.5, '13.5', id='finite-float'), + pytest.param(1.0, '1.0', id='integral-float'), + pytest.param(-0.0, '-0.0', id='negative-zero'), + pytest.param(float('nan'), 'nan', id='nan'), + pytest.param(float('inf'), 'inf', id='positive-infinity'), + pytest.param(float('-inf'), '-inf', id='negative-infinity'), + pytest.param(True, 'True', id='true'), + pytest.param(False, 'False', id='false'), + pytest.param(None, 'None', id='none'), + pytest.param(date(2026, 1, 22), '2026-01-22', id='date'), + pytest.param(datetime(2026, 1, 22, 3, 4, 5, 6), '2026-01-22T03:04:05.000006', id='naive-datetime'), + pytest.param( + datetime(2026, 1, 22, 3, 4, 5, tzinfo=timezone(timedelta(hours=3))), + '2026-01-22T03:04:05+03:00', + id='aware-datetime', + ), + ], +) +@pytest.mark.parametrize('strict_json_dict', [True, False], ids=('strict', 'non-strict')) +def test_serialize_scalar( + value: Union[str, int, float, bool, None, date, datetime], + expected: str, + strict_json_dict: bool, +): + """Serialize scalars canonically under both dictionary-key policies.""" + serialized = to_string(value, strict_json_dict=strict_json_dict) + + assert type(serialized) is str + assert serialized == expected + + +@pytest.mark.parametrize( + ('value', 'expected'), + [ + pytest.param([], '[]', id='empty-list'), + pytest.param((), '[]', id='empty-tuple'), + pytest.param({}, '{}', id='empty-dict'), + pytest.param([1, 'two', True, None], '[1, "two", true, null]', id='mixed-list'), + pytest.param((1, ('two', 3)), '[1, ["two", 3]]', id='nested-tuple'), + pytest.param({'snowman ☃': '"line\n'}, r'{"snowman \u2603": "\"line\n"}', id='escaping'), + pytest.param(['\ud800', '\udfff'], r'["\ud800", "\udfff"]', id='surrogate-strings'), + pytest.param({'second': 2, 'first': 1}, '{"second": 2, "first": 1}', id='dict-order'), + pytest.param({'items': [1, {'ok': False}]}, '{"items": [1, {"ok": false}]}', id='nested-dict'), + ], +) +def test_serialize_json_dumps_compatible_collection( + value: Union[List[object], Tuple[object, ...], Dict[str, object]], + expected: str, +): + """Serialize representative json.dumps-compatible collections to exact JSON text.""" + assert to_string(value) == expected + + +@pytest.mark.parametrize( + ('value', 'expected'), + [ + pytest.param([date(2026, 1, 22)], '["2026-01-22"]', id='date-in-list'), + pytest.param((datetime(2026, 1, 22, 3, 4, 5),), '["2026-01-22T03:04:05"]', id='datetime-in-tuple'), + pytest.param({'date': date(2026, 1, 22)}, '{"date": "2026-01-22"}', id='date-in-dict'), + pytest.param( + {'values': [(date(2026, 1, 22), datetime(2026, 1, 22, 3, 4, 5))]}, + '{"values": [["2026-01-22", "2026-01-22T03:04:05"]]}', + id='deeply-nested', + ), + pytest.param( + [ + datetime( + 2026, + 1, + 22, + 3, + 4, + 5, + 6789, + tzinfo=timezone(timedelta(microseconds=1)), + ), + ], + '["2026-01-22T03:04:05.006789+00:00:00.000001"]', + id='aware-datetime-with-subsecond-offset', + ), + ], +) +def test_serialize_nested_temporal_values( + value: Union[List[object], Tuple[object, ...], Dict[str, object]], + expected: str, +): + """Encode dates and datetimes nested in collections as quoted ISO strings.""" + assert to_string(value) == expected + + +@pytest.mark.parametrize( + ('value', 'expected'), + [ + pytest.param([None], '[null]', id='list'), + pytest.param((None,), '[null]', id='tuple'), + pytest.param({'value': None}, '{"value": null}', id='dict'), + pytest.param([{'value': (None,)}], '[{"value": [null]}]', id='nested'), + ], +) +def test_none_serializes_as_null_only_inside_collections( + value: Union[List[object], Tuple[object, ...], Dict[str, object]], + expected: str, +): + """Distinguish top-level None from JSON null inside every supported collection.""" + top_level_text = to_string(None) + nested_text = to_string(value) + + assert top_level_text == 'None' + assert nested_text == expected + assert nested_text != top_level_text + + +@given(data=strategies.data()) +@settings(max_examples=200, deadline=None) +def test_json_dumps_compatible_output_matches_json_dumps(data: strategies.DataObject): + """Match json.dumps output exactly across generated json.dumps-compatible collection trees.""" + scalars = strategies.one_of(strategies.none(), strategies.booleans(), strategies.integers(), strategies.floats(), strategies.text()) + values = strategies.recursive( + scalars, + lambda children: strategies.one_of( + strategies.lists(children, max_size=5), + strategies.lists(children, max_size=5).map(tuple), + strategies.dictionaries(strategies.text(), children, max_size=5), + ), + max_leaves=20, + ) + collections = strategies.one_of( + strategies.lists(values, max_size=8), + strategies.lists(values, max_size=8).map(tuple), + strategies.dictionaries(strategies.text(), values, max_size=8), + ) + value = data.draw(collections) + + assert to_string(value) == json.dumps(value) + + +@pytest.mark.parametrize( + ('value', 'expected'), + [ + pytest.param({}, '{}', id='empty'), + pytest.param({'key': 'value'}, '{"key": "value"}', id='simple'), + pytest.param({'outer': {'inner': 1}}, '{"outer": {"inner": 1}}', id='nested'), + ], +) +def test_strict_json_dict_accepts_string_keys(value: Dict[str, object], expected: str): + """Accept empty, flat, and nested dictionaries whose keys are exact strings.""" + assert to_string(value) == expected + + +@pytest.mark.parametrize( + 'key', + [ + pytest.param(1, id='int'), + pytest.param(1.5, id='float'), + pytest.param(True, id='bool'), + pytest.param(None, id='none'), + ], +) +@pytest.mark.parametrize('invalid_first', [True, False], ids=('invalid-first', 'invalid-last')) +def test_strict_json_dict_rejects_supported_non_string_keys( + key: Union[int, float, bool, None], + invalid_first: bool, +): + """Raise NonRoundTrippableKeyError for each supported non-string key type before and after a valid key.""" + message = ( + f'Dictionary key {key!r} of type {type(key).__name__} cannot be serialized without changing its type. ' + 'Pass strict_json_dict=False to allow lossy serialization.' + ) + + dictionary = {key: 'value', 'valid': 'value'} if invalid_first else {'valid': 'value', key: 'value'} + + with pytest.raises(NonRoundTrippableKeyError, match=match(message)) as exception_info: + to_string(dictionary) + + assert type(exception_info.value) is NonRoundTrippableKeyError + + +@pytest.mark.parametrize('invalid_first', [True, False], ids=('invalid-first', 'invalid-last')) +@pytest.mark.parametrize( + ('deeply_nested', 'key'), + [ + pytest.param(False, 1, id='one-level'), + pytest.param(True, 1.5, id='deep'), + ], +) +def test_strict_json_dict_rejects_nested_lossy_keys( + deeply_nested: bool, + key: Union[int, float], + invalid_first: bool, +): + """Reject and identify lossy keys in shallow and deeply nested dictionaries, regardless of insertion position.""" + message = ( + f'Dictionary key {key!r} of type {type(key).__name__} cannot be serialized without changing its type. ' + 'Pass strict_json_dict=False to allow lossy serialization.' + ) + dictionary = {key: 'value', 'valid': 'value'} if invalid_first else {'valid': 'value', key: 'value'} + value = [{'outer': ({'inner': dictionary},)}] if deeply_nested else {'outer': dictionary} + + with pytest.raises(NonRoundTrippableKeyError, match=match(message)): + to_string(value) + + +@pytest.mark.parametrize( + ('value', 'expected'), + [ + pytest.param({1: 'value'}, '{"1": "value"}', id='int'), + pytest.param({1.5: 'value'}, '{"1.5": "value"}', id='float'), + pytest.param({1.0: 'value'}, '{"1.0": "value"}', id='integral-float'), + pytest.param({-0.0: 'value'}, '{"-0.0": "value"}', id='negative-zero-float'), + pytest.param({float('nan'): 'value'}, '{"NaN": "value"}', id='nan-float'), + pytest.param({float('inf'): 'value'}, '{"Infinity": "value"}', id='positive-infinity-float'), + pytest.param({float('-inf'): 'value'}, '{"-Infinity": "value"}', id='negative-infinity-float'), + pytest.param({True: 'value'}, '{"true": "value"}', id='bool'), + pytest.param({False: 'value'}, '{"false": "value"}', id='false-bool'), + pytest.param({None: 'value'}, '{"null": "value"}', id='none'), + pytest.param({'outer': {1: 'value'}}, '{"outer": {"1": "value"}}', id='nested'), + pytest.param([({1: 'value'},)], '[[{"1": "value"}]]', id='nested-through-list-and-tuple'), + ], +) +def test_non_strict_json_dict_accepts_lossy_keys_recursively( + value: Union[ + List[object], + Dict[Union[str, int, float, bool, None], object], + ], + expected: str, +): + """Serialize all native lossy root-key types and integer keys through nested collection layers.""" + assert to_string(value, strict_json_dict=False) == expected + + +@given(strategies.one_of(strategies.integers(), strategies.floats(), strategies.booleans(), strategies.none())) +@settings(max_examples=200, deadline=None) +def test_non_strict_json_dict_key_format_matches_canonical_form( + key: Union[int, float, bool, None], +): + """Match json.dumps names for generated numeric, boolean, and None keys.""" + expected = json.dumps({'valid': 'value', key: 'lossy'}) + + assert to_string({'valid': 'value', key: 'lossy'}, strict_json_dict=False) == expected + + +@pytest.mark.parametrize( + ('value', 'expected'), + [ + pytest.param({1: 'int', '1': 'string'}, '{"1": "int", "1": "string"}', id='int-and-string'), + pytest.param({'1': 'string', 1: 'int'}, '{"1": "string", "1": "int"}', id='string-and-int'), + pytest.param( + {1.0: 'float', '1.0': 'string'}, + '{"1.0": "float", "1.0": "string"}', + id='float-and-string', + ), + pytest.param( + {-0.0: 'float', '-0.0': 'string'}, + '{"-0.0": "float", "-0.0": "string"}', + id='negative-zero-and-string', + ), + pytest.param( + {float('nan'): 'float', 'NaN': 'string'}, + '{"NaN": "float", "NaN": "string"}', + id='nan-and-string', + ), + pytest.param( + {float('inf'): 'float', 'Infinity': 'string'}, + '{"Infinity": "float", "Infinity": "string"}', + id='positive-infinity-and-string', + ), + pytest.param( + {float('-inf'): 'float', '-Infinity': 'string'}, + '{"-Infinity": "float", "-Infinity": "string"}', + id='negative-infinity-and-string', + ), + pytest.param( + {True: 'bool', 'true': 'string'}, + '{"true": "bool", "true": "string"}', + id='true-and-string', + ), + pytest.param( + {False: 'bool', 'false': 'string'}, + '{"false": "bool", "false": "string"}', + id='false-and-string', + ), + pytest.param( + {None: 'none', 'null': 'string'}, + '{"null": "none", "null": "string"}', + id='none-and-string', + ), + pytest.param( + {'outer': {1: 'int', '1': 'string'}}, + '{"outer": {"1": "int", "1": "string"}}', + id='nested-int-and-string', + ), + ], +) +def test_non_strict_json_dict_allows_canonical_collisions( + value: Dict[Union[str, int, float, bool, None], object], + expected: str, +): + """Preserve insertion order when distinct Python keys yield duplicate JSON property names, including in nested dictionaries.""" + assert to_string(value, strict_json_dict=False) == expected + + +def test_integer_key_does_not_round_trip_in_non_strict_mode(): + """After non-strict serialization, reject int restoration and restore the dictionary key as str.""" + value = {1: 'value'} + + serialized = to_string(value, strict_json_dict=False) + message = 'The string "{"1": "value"}" cannot be interpreted as a dict of the specified format.' + + with pytest.raises(TypeError, match=match(message)) as exception_info: + from_string(serialized, Dict[int, str]) + + assert type(exception_info.value) is TypeError + + restored = from_string(serialized, Dict[str, str]) + + assert restored == {'1': 'value'} + assert type(next(iter(restored))) is str + assert restored != value + + +@pytest.mark.parametrize( + 'key_case', + [ + 'date', + 'datetime', + 'tuple', + 'custom-hashable', + 'string-subclass', + 'int-subclass', + 'float-subclass', + 'date-subclass', + 'datetime-subclass', + 'deceptive-bool', + 'deceptive-none', + ], +) +@pytest.mark.parametrize('strict_json_dict', [True, False], ids=('strict', 'non-strict')) +@pytest.mark.parametrize('nested', [False, True], ids=('top-level', 'nested')) +@pytest.mark.parametrize('invalid_first', [True, False], ids=('invalid-first', 'invalid-last')) +def test_json_dict_rejects_unsupported_keys_at_any_depth_in_both_modes( + key_case: str, + strict_json_dict: bool, + nested: bool, + invalid_first: bool, +): + """Raise built-in TypeError for representative unsupported keys across both policies, insertion positions, and tested depths.""" + class HashableObject: + ... + + class EqualToBaseMeta(type): + _equal_type: Type[object] + + def __eq__(cls, other: object) -> bool: + return other is cls._equal_type + + def __hash__(cls) -> int: + return hash(cls._equal_type) + + class StringSubclass(str, metaclass=EqualToBaseMeta): # type: ignore[misc] + __slots__ = () + _equal_type = str + + class IntSubclass(int, metaclass=EqualToBaseMeta): + _equal_type = int + + class FloatSubclass(float, metaclass=EqualToBaseMeta): + _equal_type = float + + class DateSubclass(date, metaclass=EqualToBaseMeta): + _equal_type = date + + class DatetimeSubclass(datetime, metaclass=EqualToBaseMeta): + _equal_type = datetime + + class DeceptiveBool(metaclass=EqualToBaseMeta): + _equal_type = bool + + class DeceptiveNone(metaclass=EqualToBaseMeta): + _equal_type = type(None) + + keys = { + 'date': date(2026, 1, 22), + 'datetime': datetime(2026, 1, 22, 3, 4, 5), + 'tuple': (1, 2), + 'custom-hashable': HashableObject(), + 'string-subclass': StringSubclass('key'), + 'int-subclass': IntSubclass(7), + 'float-subclass': FloatSubclass(1.5), + 'date-subclass': DateSubclass(2026, 1, 22), + 'datetime-subclass': DatetimeSubclass(2026, 1, 22, 3, 4, 5), + 'deceptive-bool': DeceptiveBool(), + 'deceptive-none': DeceptiveNone(), + } + key = keys[key_case] + message = f'Dictionary key {key!r} of type {type(key).__name__} cannot be serialized to JSON.' + dictionary = {key: 'value', 'valid': 'value'} if invalid_first else {'valid': 'value', key: 'value'} + value = [{'outer': (dictionary,)}] if nested else dictionary + + with pytest.raises(TypeError, match=match(message)) as exception_info: + to_string(value, strict_json_dict=strict_json_dict) + + assert type(exception_info.value) is TypeError + + +def test_strict_json_dict_is_keyword_only(): + """Require strict_json_dict to be keyword-only.""" + with pytest.raises( + TypeError, + match=match('to_string() takes 1 positional argument but 2 were given'), + ): + to_string({}, False) # type: ignore[misc] + + +@pytest.mark.parametrize( + 'flag_case', + [ + 'zero-int', + 'one-int', + 'none', + 'string', + 'deceptive-bool', + ], +) +@pytest.mark.parametrize('value', [1, {'key': 'value'}], ids=('scalar', 'dict')) +def test_strict_json_dict_must_be_bool( + value: Union[int, Dict[str, str]], + flag_case: str, +): + """Require strict_json_dict to be an exact bool for scalar and dictionary inputs.""" + class EqualToBoolMeta(type): + def __eq__(cls, other: object) -> bool: + return other is bool + + def __hash__(cls) -> int: + return hash(bool) + + class DeceptiveBool(metaclass=EqualToBoolMeta): + ... + + invalid_flags = { + 'zero-int': 0, + 'one-int': 1, + 'none': None, + 'string': 'false', + 'deceptive-bool': DeceptiveBool(), + } + + with pytest.raises(TypeError, match=match('strict_json_dict must be a bool.')): + to_string(value, strict_json_dict=invalid_flags[flag_case]) # type: ignore[arg-type] + + +@pytest.mark.parametrize( + 'value_case', + [ + 'set', + 'bytes', + 'object', + 'str-subclass', + 'int-subclass', + 'float-subclass', + 'date-subclass', + 'datetime-subclass', + 'list-subclass', + 'tuple-subclass', + 'dict-subclass', + 'deceptive-bool', + 'deceptive-none', + ], +) +@pytest.mark.parametrize('strict_json_dict', [True, False], ids=('strict', 'non-strict')) +def test_to_string_rejects_unsupported_top_level_value(value_case: str, strict_json_dict: bool): + """Reject unsupported top-level values under both key policies.""" + class EqualToBaseMeta(type): + _equal_type: Type[object] + + def __eq__(cls, other: object) -> bool: + return other is cls._equal_type + + def __hash__(cls) -> int: + return hash(cls._equal_type) + + class StringSubclass(str, metaclass=EqualToBaseMeta): # type: ignore[misc] + __slots__ = () + _equal_type = str + + class IntSubclass(int, metaclass=EqualToBaseMeta): + _equal_type = int + + class FloatSubclass(float, metaclass=EqualToBaseMeta): + _equal_type = float + + class DateSubclass(date, metaclass=EqualToBaseMeta): + _equal_type = date + + class DatetimeSubclass(datetime, metaclass=EqualToBaseMeta): + _equal_type = datetime + + class ListSubclass(list, metaclass=EqualToBaseMeta): # type: ignore[misc] + _equal_type = list + + class TupleSubclass(tuple, metaclass=EqualToBaseMeta): # type: ignore[misc] + __slots__ = () + _equal_type = tuple + + class DictSubclass(dict, metaclass=EqualToBaseMeta): # type: ignore[misc] + _equal_type = dict + + class DeceptiveBool(metaclass=EqualToBaseMeta): + _equal_type = bool + + class DeceptiveNone(metaclass=EqualToBaseMeta): + _equal_type = type(None) + + values = { + 'set': {1, 2}, + 'bytes': b'bytes', + 'object': object(), + 'str-subclass': StringSubclass('value'), + 'int-subclass': IntSubclass(1), + 'float-subclass': FloatSubclass(1.5), + 'date-subclass': DateSubclass(2026, 1, 22), + 'datetime-subclass': DatetimeSubclass(2026, 1, 22, 3, 4, 5), + 'list-subclass': ListSubclass([1]), + 'tuple-subclass': TupleSubclass((1,)), + 'dict-subclass': DictSubclass({'key': 'value'}), + 'deceptive-bool': DeceptiveBool(), + 'deceptive-none': DeceptiveNone(), + } + value = values[value_case] + message = ( + f'Serialization of the type {type(value).__name__} is not supported. ' + 'Supported types: str, int, float, bool, NoneType, date, datetime, list, tuple, dict.' + ) + + with pytest.raises(TypeError, match=match(message)) as exception_info: + to_string(value, strict_json_dict=strict_json_dict) + + assert type(exception_info.value) is TypeError + + +@pytest.mark.parametrize( + 'value_case', + [ + 'list-invalid-first', + 'list', + 'tuple-invalid-first', + 'tuple', + 'dict-invalid-first', + 'dict', + 'deep-invalid-first', + 'deep', + 'int-subclass', + 'float-subclass', + 'date-subclass', + 'datetime-subclass', + 'list-subclass', + 'tuple-subclass', + 'dict-subclass', + 'deceptive-bool', + 'deceptive-none', + ], +) +@pytest.mark.parametrize('strict_json_dict', [True, False], ids=('strict', 'non-strict')) +def test_to_string_rejects_nested_unsupported_type( + value_case: str, + strict_json_dict: bool, +): + """Reject unsupported nested values in either mode and name their exact type.""" + class EqualToBaseMeta(type): + _equal_type: Type[object] + + def __eq__(cls, other: object) -> bool: + return other is cls._equal_type + + def __hash__(cls) -> int: + return hash(cls._equal_type) + + class StringSubclass(str, metaclass=EqualToBaseMeta): # type: ignore[misc] + __slots__ = () + _equal_type = str + + class IntSubclass(int, metaclass=EqualToBaseMeta): + _equal_type = int + + class FloatSubclass(float, metaclass=EqualToBaseMeta): + _equal_type = float + + class DateSubclass(date, metaclass=EqualToBaseMeta): + _equal_type = date + + class DatetimeSubclass(datetime, metaclass=EqualToBaseMeta): + _equal_type = datetime + + class ListSubclass(list, metaclass=EqualToBaseMeta): # type: ignore[misc] + _equal_type = list + + class TupleSubclass(tuple, metaclass=EqualToBaseMeta): # type: ignore[misc] + __slots__ = () + _equal_type = tuple + + class DictSubclass(dict, metaclass=EqualToBaseMeta): # type: ignore[misc] + _equal_type = dict + + class DeceptiveBool(metaclass=EqualToBaseMeta): + _equal_type = bool + + class DeceptiveNone(metaclass=EqualToBaseMeta): + _equal_type = type(None) + + cases = { + 'list-invalid-first': ([{1, 2}, 'valid'], 'set'), + 'list': (['valid', {1, 2}], 'set'), + 'tuple-invalid-first': ((b'bytes', 'valid'), 'bytes'), + 'tuple': (('valid', b'bytes'), 'bytes'), + 'dict-invalid-first': ({'invalid': object(), 'valid': 'value'}, 'object'), + 'dict': ({'valid': 'value', 'invalid': object()}, 'object'), + 'deep-invalid-first': ( + {'outer': [{'invalid': StringSubclass('nested'), 'valid': 'value'}]}, + 'StringSubclass', + ), + 'deep': ( + {'outer': [{'valid': 'value', 'invalid': StringSubclass('nested')}]}, + 'StringSubclass', + ), + 'int-subclass': ([0, IntSubclass(7)], 'IntSubclass'), + 'float-subclass': ((0.0, FloatSubclass(1.5)), 'FloatSubclass'), + 'date-subclass': ([date(2025, 1, 1), DateSubclass(2026, 1, 22)], 'DateSubclass'), + 'datetime-subclass': ( + {'valid': datetime(2025, 1, 1), 'invalid': DatetimeSubclass(2026, 1, 22, 3, 4, 5)}, + 'DatetimeSubclass', + ), + 'list-subclass': ([[], ListSubclass([1])], 'ListSubclass'), + 'tuple-subclass': ({'valid': (), 'invalid': TupleSubclass((1,))}, 'TupleSubclass'), + 'dict-subclass': (({}, DictSubclass({'key': 'value'})), 'DictSubclass'), + 'deceptive-bool': ([False, DeceptiveBool()], 'DeceptiveBool'), + 'deceptive-none': ([None, DeceptiveNone()], 'DeceptiveNone'), + } + value, unsupported_type_name = cases[value_case] + message = ( + f'Serialization of the type {unsupported_type_name} is not supported. ' + 'Supported types: str, int, float, bool, NoneType, date, datetime, list, tuple, dict.' + ) + + with pytest.raises(TypeError, match=match(message)) as exception_info: + to_string(value, strict_json_dict=strict_json_dict) + + assert type(exception_info.value) is TypeError + + +def test_public_to_string_api(): + """Expose callable to_string and NonRoundTrippableKeyError as a distinct TypeError subclass.""" + from simtypes.errors import ( # noqa: PLC0415 + NonRoundTrippableKeyError as NonRoundTrippableKeyErrorFromModule, + ) + + assert callable(to_string) + assert NonRoundTrippableKeyErrorFromModule is NonRoundTrippableKeyError + assert NonRoundTrippableKeyError is not TypeError + assert issubclass(NonRoundTrippableKeyError, TypeError) + + +@given( + strategies.one_of( + strategies.tuples(strategies.text(), strategies.just(str)), + strategies.tuples(strategies.integers(), strategies.just(int)), + strategies.tuples(strategies.floats(), strategies.just(float)), + strategies.tuples(strategies.booleans(), strategies.just(bool)), + strategies.tuples(strategies.none(), strategies.sampled_from((None, type(None)))), + strategies.tuples(strategies.dates(), strategies.just(date)), + strategies.tuples( + strategies.datetimes( + timezones=strategies.one_of( + strategies.none(), + strategies.builds( + timezone, + strategies.builds( + timedelta, + microseconds=strategies.one_of( + strategies.just(0), + strategies.integers(min_value=-86_399_999_999, max_value=-1_000_000), + strategies.integers(min_value=1_000_000, max_value=86_399_999_999), + ), + ), + ), + ), + ), + strategies.just(datetime), + ), + ), +) +@example(('\ud800', str)) +@example(('\udfff', str)) +@example((None, None)) +@example((None, type(None))) +@example((-0.0, float)) +@example((float('inf'), float)) +@example((float('-inf'), float)) +@example((float('nan'), float)) +@example((datetime(2026, 1, 22, 3, 4, 5, tzinfo=timezone(timedelta(hours=3))), datetime)) +@example((datetime(2026, 1, 22, 3, 4, 5, fold=1), datetime)) +@settings(max_examples=200, deadline=None) +def test_round_trippable_scalar_values_serialize_canonically_and_round_trip(case): + """Require canonical scalar text and exact-type round trips under NaN, signed-zero, and datetime equivalence rules.""" + value, expected_type = case + serialized = to_string(value) + + if type(value) is date or type(value) is datetime: + canonical_text = value.isoformat() + else: + canonical_text = str(value) + + restored = from_string(serialized, expected_type) + + assert serialized == canonical_text + assert type(restored) is type(value) + + if type(value) is float: + if math.isnan(value): + assert math.isnan(restored) + else: + assert restored == value + if value == 0.0: + assert math.copysign(1.0, restored) == math.copysign(1.0, value) + elif type(value) is datetime: + value_fields = ( + value.year, + value.month, + value.day, + value.hour, + value.minute, + value.second, + value.microsecond, + ) + restored_fields = ( + restored.year, + restored.month, + restored.day, + restored.hour, + restored.minute, + restored.second, + restored.microsecond, + ) + assert restored_fields == value_fields + assert restored.utcoffset() == value.utcoffset() + else: + assert restored == value + + +def test_datetime_round_trip_preserves_fields_and_offset_but_loses_tzinfo_identity_name_and_fold(): + """Preserve datetime fields and UTC offset, but not fold, tzinfo identity, or tzinfo name.""" + original_timezone = timezone(timedelta(hours=3), 'named timezone') + value = datetime(2026, 1, 22, 3, 4, 5, 6789, tzinfo=original_timezone, fold=1) + + restored = from_string(to_string(value), datetime) + + assert type(restored) is datetime + assert restored.replace(tzinfo=None) == value.replace(tzinfo=None) + assert restored.utcoffset() == value.utcoffset() + assert restored.tzinfo is not original_timezone + assert value.tzname() == 'named timezone' + assert restored.tzname() == 'UTC+03:00' + assert restored.fold == 0 + + +@pytest.mark.parametrize('offset_microseconds', [1, 999_999, -1, -999_999]) +def test_datetime_subsecond_offset_serializes_exactly_but_does_not_round_trip( + offset_microseconds: int, +): + """Serialize nonzero subsecond UTC offsets exactly; from_string preserves wall time but sets the offset to zero.""" + value = datetime( + 2026, + 1, + 22, + 3, + 4, + 5, + tzinfo=timezone(timedelta(microseconds=offset_microseconds)), + ) + + serialized = to_string(value) + restored = from_string(serialized, datetime) + + assert serialized == value.isoformat() + assert restored.replace(tzinfo=None) == value.replace(tzinfo=None) + assert value.utcoffset() == timedelta(microseconds=offset_microseconds) + assert restored.utcoffset() == timedelta(0) + + +@pytest.mark.parametrize( + 'temporal_value', + [ + pytest.param(date(2026, 1, 22), id='date'), + pytest.param( + datetime(2026, 1, 22, 3, 4, 5, 6789, tzinfo=timezone(timedelta(hours=3))), + id='datetime', + ), + ], +) +def test_temporal_values_round_trip_in_every_collection( + temporal_value: Union[date, datetime], + subscribable_list_type, + subscribable_tuple_type, + subscribable_dict_type, +): + """Round-trip exact date and datetime values through every typing and built-in collection annotation.""" + temporal_type = type(temporal_value) + cases = ( + ([temporal_value], subscribable_list_type[temporal_type]), + ((temporal_value,), subscribable_tuple_type[temporal_type, ...]), + ({'value': temporal_value}, subscribable_dict_type[str, temporal_type]), + ) + + for value, expected_type in cases: + restored = from_string(to_string(value), expected_type) + + assert type(restored) is type(value) + assert len(restored) == len(value) + + if type(value) is dict: + assert list(restored) == list(value) + assert type(next(iter(restored))) is str + restored_temporal_value = restored['value'] + else: + restored_temporal_value = restored[0] + + assert type(restored_temporal_value) is type(temporal_value) + + if type(temporal_value) is datetime: + temporal_fields = ( + temporal_value.year, + temporal_value.month, + temporal_value.day, + temporal_value.hour, + temporal_value.minute, + temporal_value.second, + temporal_value.microsecond, + ) + restored_fields = ( + restored_temporal_value.year, + restored_temporal_value.month, + restored_temporal_value.day, + restored_temporal_value.hour, + restored_temporal_value.minute, + restored_temporal_value.second, + restored_temporal_value.microsecond, + ) + assert restored_fields == temporal_fields + assert restored_temporal_value.utcoffset() == temporal_value.utcoffset() + else: + assert restored_temporal_value == temporal_value + + +@given(data=strategies.data()) +@settings(max_examples=200, deadline=None) +def test_recursive_collection_round_trip_is_independent_of_key_policy( # noqa: C901, PLR0915 + data: strategies.DataObject, +): + """Round-trip generated collection trees with identical text under both key policies.""" + offset_microseconds = strategies.one_of( + strategies.just(0), + strategies.integers(min_value=-86_399_999_999, max_value=-1_000_000), + strategies.integers(min_value=1_000_000, max_value=86_399_999_999), + ) + fixed_timezones = offset_microseconds.map( + lambda microseconds: timezone(timedelta(microseconds=microseconds)), + ) + scalar_value_strategies = { + 'str': strategies.text(), + 'int': strategies.integers(), + 'float': strategies.floats(), + 'bool': strategies.booleans(), + 'none': strategies.none(), + 'date': strategies.dates(), + 'datetime': strategies.datetimes(timezones=strategies.one_of(strategies.none(), fixed_timezones)), + } + + def values_for_spec(spec) -> strategies.SearchStrategy[object]: + """Build a value strategy recursively from a generated type specification.""" + if isinstance(spec, str): + return scalar_value_strategies[spec] + + kind = spec[0] + if kind == 'list': + return strategies.lists(values_for_spec(spec[1]), max_size=5) + if kind == 'tuple': + return strategies.lists(values_for_spec(spec[1]), max_size=5).map(tuple) + if kind == 'dict': + return strategies.dictionaries(strategies.text(), values_for_spec(spec[1]), max_size=5) + return strategies.tuples(values_for_spec(spec[1]), values_for_spec(spec[2])) + + def annotation_for_spec(spec): + """Build a precise annotation recursively from a generated type specification.""" + scalar_annotations = { + 'str': str, + 'int': int, + 'float': float, + 'bool': bool, + 'none': type(None), + 'date': date, + 'datetime': datetime, + } + if spec in scalar_annotations: + return scalar_annotations[spec] + + kind = spec[0] + if kind == 'list': + return List[annotation_for_spec(spec[1])] # type: ignore[misc] + if kind == 'tuple': + return Tuple[annotation_for_spec(spec[1]), ...] + if kind == 'dict': + return Dict[str, annotation_for_spec(spec[1])] # type: ignore[misc] + return Tuple[annotation_for_spec(spec[1]), annotation_for_spec(spec[2])] # type: ignore[misc] + + def assert_round_trip_equivalent(expected, actual): + """Compare round-trip values recursively using the contract's exact-type rules.""" + assert type(actual) is type(expected) + + if type(expected) is float: + if math.isnan(expected): + assert math.isnan(actual) + else: + assert actual == expected + if expected == 0.0: + assert math.copysign(1.0, actual) == math.copysign(1.0, expected) + return + + if type(expected) is datetime: + expected_fields = ( + expected.year, + expected.month, + expected.day, + expected.hour, + expected.minute, + expected.second, + expected.microsecond, + ) + actual_fields = ( + actual.year, + actual.month, + actual.day, + actual.hour, + actual.minute, + actual.second, + actual.microsecond, + ) + assert actual_fields == expected_fields + assert actual.utcoffset() == expected.utcoffset() + return + + if type(expected) in (list, tuple): + assert len(actual) == len(expected) + for expected_element, actual_element in zip(expected, actual): + assert_round_trip_equivalent(expected_element, actual_element) + return + + if type(expected) is dict: + assert list(actual) == list(expected) + for key in expected: + assert type(next(actual_key for actual_key in actual if actual_key == key)) is type(key) + assert_round_trip_equivalent(expected[key], actual[key]) + return + + assert actual == expected + + scalar_specs = strategies.sampled_from(('str', 'int', 'float', 'bool', 'none', 'date', 'datetime')) + type_specs = strategies.recursive( + scalar_specs, + lambda children: strategies.one_of( + strategies.tuples(strategies.just('list'), children), + strategies.tuples(strategies.just('tuple'), children), + strategies.tuples(strategies.just('dict'), children), + strategies.tuples(strategies.just('fixed_tuple'), children, children), + ), + max_leaves=8, + ) + spec = data.draw(type_specs.filter(lambda candidate: isinstance(candidate, tuple))) + value = data.draw(values_for_spec(spec)) + expected_type = annotation_for_spec(spec) + + serialized = to_string(value) + assert to_string(value, strict_json_dict=False) == serialized + + restored = from_string(serialized, expected_type) + assert_round_trip_equivalent(value, restored) + + +@given(data=strategies.data()) +@settings(max_examples=200, deadline=None) +def test_dicts_with_exact_str_keys_round_trip(data: strategies.DataObject): # noqa: C901 + """Round-trip generated dictionaries whose keys have exact type str using a precise recursive annotation for their values.""" + offset_microseconds = strategies.one_of( + strategies.just(0), + strategies.integers(min_value=-86_399_999_999, max_value=-1_000_000), + strategies.integers(min_value=1_000_000, max_value=86_399_999_999), + ) + fixed_timezones = offset_microseconds.map( + lambda microseconds: timezone(timedelta(microseconds=microseconds)), + ) + scalar_value_strategies = { + 'str': strategies.text(), + 'int': strategies.integers(), + 'float': strategies.floats(), + 'bool': strategies.booleans(), + 'none': strategies.none(), + 'date': strategies.dates(), + 'datetime': strategies.datetimes(timezones=strategies.one_of(strategies.none(), fixed_timezones)), + } + + def values_for_spec(spec) -> strategies.SearchStrategy[object]: + """Build a value strategy recursively from a generated type specification.""" + if isinstance(spec, str): + return scalar_value_strategies[spec] + + kind = spec[0] + if kind == 'list': + return strategies.lists(values_for_spec(spec[1]), max_size=5) + if kind == 'tuple': + return strategies.lists(values_for_spec(spec[1]), max_size=5).map(tuple) + if kind == 'dict': + return strategies.dictionaries(strategies.text(), values_for_spec(spec[1]), max_size=5) + return strategies.tuples(values_for_spec(spec[1]), values_for_spec(spec[2])) + + def annotation_for_spec(spec): + """Build a precise annotation recursively from a generated type specification.""" + scalar_annotations = { + 'str': str, + 'int': int, + 'float': float, + 'bool': bool, + 'none': type(None), + 'date': date, + 'datetime': datetime, + } + if spec in scalar_annotations: + return scalar_annotations[spec] + + kind = spec[0] + if kind == 'list': + return List[annotation_for_spec(spec[1])] # type: ignore[misc] + if kind == 'tuple': + return Tuple[annotation_for_spec(spec[1]), ...] + if kind == 'dict': + return Dict[str, annotation_for_spec(spec[1])] # type: ignore[misc] + return Tuple[annotation_for_spec(spec[1]), annotation_for_spec(spec[2])] # type: ignore[misc] + + def assert_round_trip_equivalent(expected, actual): + """Compare round-trip values recursively using the contract's exact-type rules.""" + assert type(actual) is type(expected) + + if type(expected) is float: + if math.isnan(expected): + assert math.isnan(actual) + else: + assert actual == expected + if expected == 0.0: + assert math.copysign(1.0, actual) == math.copysign(1.0, expected) + return + + if type(expected) is datetime: + expected_fields = ( + expected.year, + expected.month, + expected.day, + expected.hour, + expected.minute, + expected.second, + expected.microsecond, + ) + actual_fields = ( + actual.year, + actual.month, + actual.day, + actual.hour, + actual.minute, + actual.second, + actual.microsecond, + ) + assert actual_fields == expected_fields + assert actual.utcoffset() == expected.utcoffset() + return + + if type(expected) in (list, tuple): + assert len(actual) == len(expected) + for expected_element, actual_element in zip(expected, actual): + assert_round_trip_equivalent(expected_element, actual_element) + return + + if type(expected) is dict: + assert list(actual) == list(expected) + for key in expected: + assert type(next(actual_key for actual_key in actual if actual_key == key)) is type(key) + assert_round_trip_equivalent(expected[key], actual[key]) + return + + assert actual == expected + + scalar_specs = strategies.sampled_from(('str', 'int', 'float', 'bool', 'none', 'date', 'datetime')) + type_specs = strategies.recursive( + scalar_specs, + lambda children: strategies.one_of( + strategies.tuples(strategies.just('list'), children), + strategies.tuples(strategies.just('tuple'), children), + strategies.tuples(strategies.just('dict'), children), + strategies.tuples(strategies.just('fixed_tuple'), children, children), + ), + max_leaves=8, + ) + value_spec = data.draw(type_specs) + value = data.draw(strategies.dictionaries(strategies.text(), values_for_spec(value_spec), max_size=8)) + expected_type: Any = Dict[str, annotation_for_spec(value_spec)] # type: ignore[misc, valid-type] + + restored = from_string(to_string(value), expected_type) + + assert_round_trip_equivalent(value, restored) + + +def test_dict_with_surrogate_strings_round_trips(): + """Round-trip exact surrogate string keys and values without changing their runtime types.""" + value = {chr(0xDFFF): chr(0xD800)} + + restored = from_string(to_string(value), Dict[str, str]) + + assert type(restored) is dict + assert list(restored) == list(value) + assert type(next(iter(restored))) is str + assert type(restored[chr(0xDFFF)]) is str + assert restored[chr(0xDFFF)] == value[chr(0xDFFF)] + + +@pytest.mark.parametrize( + ('value', 'precise_type', 'expected_erased_result'), + [ + pytest.param([date(2026, 1, 22)], List[date], ['2026-01-22'], id='date-in-list'), + pytest.param([(1, 2)], List[Tuple[int, ...]], [[1, 2]], id='tuple-in-list'), + pytest.param(((1,),), Tuple[Tuple[int, ...], ...], ([1],), id='nested-tuple'), + ], +) +def test_round_trip_requires_precise_external_type( + value: Union[ + List[date], + List[Tuple[int, ...]], + Tuple[Tuple[int, ...], ...], + ], + precise_type: Type[object], + expected_erased_result: Union[ + List[str], + List[List[int]], + Tuple[List[int], ...], + ], +): + """Precise annotations restore original values and types; bare container annotations lose nested type information.""" + def assert_value_and_types(expected, actual): + """Compare values recursively while requiring exact runtime types at every level.""" + assert type(actual) is type(expected) + + if type(expected) is list or type(expected) is tuple: + assert len(actual) == len(expected) + for expected_element, actual_element in zip(expected, actual): + assert_value_and_types(expected_element, actual_element) + return + + assert actual == expected + + serialized = to_string(value) + + restored_with_precise_type = from_string(serialized, precise_type) + restored_with_erased_type = from_string(serialized, type(value)) + + assert_value_and_types(value, restored_with_precise_type) + assert restored_with_erased_type == expected_erased_result + assert restored_with_erased_type != value + + +@pytest.mark.parametrize( + 'value', + [ + pytest.param('2026-01-22', id='str'), + pytest.param(1, id='int'), + pytest.param(1.5, id='float'), + pytest.param(True, id='bool'), + pytest.param(None, id='none'), + pytest.param(date(2026, 1, 22), id='date'), + pytest.param(datetime(2026, 1, 22, 3, 4, 5), id='datetime'), + pytest.param([1], id='list'), + pytest.param((1,), id='tuple'), + pytest.param({'key': 1}, id='dict'), + ], +) +def test_any_round_trip_succeeds_only_when_original_value_is_str( + value: Union[ + str, + int, + float, + bool, + None, + date, + datetime, + List[int], + Tuple[int, ...], + Dict[str, int], + ], +): + """from_string(to_string(value), Any) restores the original value and exact type only when type(value) is str.""" + serialized = to_string(value) + restored = from_string(serialized, Any) # type: ignore[call-overload] + + assert type(restored) is str + assert restored == serialized + assert (restored == value) is (type(value) is str) + + +def test_round_trip_with_typing_and_builtin_annotations( + subscribable_list_type, + subscribable_tuple_type, + subscribable_dict_type, +): + """Round-trip a nested value through every available typing and built-in generic alias.""" + value = {'items': [(1, 2), (3, 4)]} + expected_type = subscribable_dict_type[ + str, + subscribable_list_type[subscribable_tuple_type[int, ...]], + ] + + restored = from_string(to_string(value), expected_type) + + assert type(restored) is dict + assert list(restored) == list(value) + assert type(next(iter(restored))) is str + + restored_items = restored['items'] + assert type(restored_items) is list + assert len(restored_items) == len(value['items']) + + for expected_pair, restored_pair in zip(value['items'], restored_items): + assert type(restored_pair) is tuple + assert len(restored_pair) == len(expected_pair) + for expected_element, restored_element in zip(expected_pair, restored_pair): + assert type(restored_element) is int + assert restored_element == expected_element From dffffacca00a7698e06e50b89e7d1ee2d3b0df0a 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, 24 Aug 2026 20:06:08 +0300 Subject: [PATCH 17/24] Add mypy typing test for to_string return types --- tests/typing/test_to_string.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 tests/typing/test_to_string.py diff --git a/tests/typing/test_to_string.py b/tests/typing/test_to_string.py new file mode 100644 index 0000000..02986a6 --- /dev/null +++ b/tests/typing/test_to_string.py @@ -0,0 +1,30 @@ +from datetime import date, datetime + +try: + from typing import assert_type # type: ignore[attr-defined, unused-ignore] +except ImportError: # pragma: no cover + from typing_extensions import assert_type + +import pytest + +from simtypes import to_string + + +@pytest.mark.mypy_testing +def test_to_string_returns_str_for_supported_values(): + """Check statically and at runtime that to_string returns str for every supported category, including non-strict integer-key dictionaries.""" + serialization_results = [ + assert_type(to_string('value'), str), + assert_type(to_string(1), str), + assert_type(to_string(1.5), str), + assert_type(to_string(True), str), + assert_type(to_string(None), str), + assert_type(to_string(date(2026, 1, 22)), str), + assert_type(to_string(datetime(2026, 1, 22, 3, 4, 5)), str), + assert_type(to_string([1, 2]), str), + assert_type(to_string((1, 2)), str), + assert_type(to_string({'key': 1}), str), + assert_type(to_string({1: 'value'}, strict_json_dict=False), str), + ] + + assert all(type(serialization_result) is str for serialization_result in serialization_results) From c5bf0c62656437bdce4bc210db0a0c02c3681ea8 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, 24 Aug 2026 20:11:08 +0300 Subject: [PATCH 18/24] Document string serialization in README --- README.md | 96 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/README.md b/README.md index a505927..e99e061 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Python type checking tools are usually very complex. In this case, we have throw - [**Type checking**](#type-checking) - [**Special types**](#special-types) - [**String deserialization**](#string-deserialization) +- [**String serialization**](#string-serialization) ## Why? @@ -256,3 +257,98 @@ print(from_string('{"123": [1, 2, 3]}', dict[str, tuple[int, ...]])) ``` > 👀 If the passed string cannot be interpreted as an object of the specified type, a `TypeError` exception will be raised. + + +## String serialization + +The library also provides basic serialization. The `to_string` function is the reverse operation for `from_string`: it converts supported Python values into strings. + +The following exact types are supported: + +- `str` - strings are returned unchanged. +- `NoneType` - `None` is converted to the string `"None"`. +- `int` - integers use their standard Python string representation. +- `float` - floating-point numbers use their standard Python string representation, including infinities, [`NaN`](https://en.wikipedia.org/wiki/NaN), and negative zero. +- `bool` - boolean values are converted to `"True"` or `"False"`. +- `date` or `datetime` - dates and datetimes are converted to [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) strings. +- `list` - lists are converted to [`JSON`](https://en.wikipedia.org/wiki/JSON) arrays. +- `tuple` - tuples are converted to [`JSON`](https://en.wikipedia.org/wiki/JSON) arrays. +- `dict` - dictionaries with exact string keys are converted to [`JSON`](https://en.wikipedia.org/wiki/JSON) objects. + +Inside collections, `None` becomes `null`, boolean values use the JSON spelling, and `date` and `datetime` values become ISO-formatted JSON strings. Subclasses of supported types and all other types raise `TypeError`. + +The full function signature is: + +```python +def to_string(value: Any, *, strict_json_dict: bool = True) -> str: + ... +``` + +Examples: + +```python +from datetime import date, datetime +from typing import Dict, List, Tuple + +from simtypes import NonRoundTrippableKeyError, from_string, to_string + +# scalars +print(to_string('text')) +#> text +print(to_string(13)) +#> 13 +print(to_string(True)) +#> True +print(to_string(None)) +#> None +print(to_string(date(2026, 1, 22))) +#> 2026-01-22 +print(to_string(datetime(2026, 1, 22, 3, 4, 5))) +#> 2026-01-22T03:04:05 + +# collections +value = { + 'items': (1, None), + 'dates': [date(2026, 1, 22)], +} + +print(to_string(value)) +#> {"items": [1, null], "dates": ["2026-01-22"]} + +# round-trip +integer = 13 +print(from_string(to_string(integer), int)) +#> 13 + +items = [(1, 2), (3, 4)] +items_type = List[Tuple[int, ...]] +print(from_string(to_string(items), items_type)) +#> [(1, 2), (3, 4)] + +temporal = {'dates': [date(2026, 1, 22)]} +temporal_type = Dict[str, List[date]] +print(from_string(to_string(temporal), temporal_type)) +#> {'dates': [datetime.date(2026, 1, 22)]} + +# dictionary keys +try: + to_string({1: 'value'}) +except NonRoundTrippableKeyError as error: + print(error) +#> Dictionary key 1 of type int cannot be serialized without changing its type. Pass strict_json_dict=False to allow lossy serialization. + +serialized = to_string({1: 'value'}, strict_json_dict=False) +print(serialized) +#> {"1": "value"} +print(from_string(serialized, Dict[str, str])) +#> {'1': 'value'} +``` + +For round-trip, retain the complete expected type, including generic arguments, and pass it to `from_string`. The serialized text contains no type information: the same JSON array can represent a Python list or tuple. A round-trip through `Any` preserves only values whose exact type is `str`, because `from_string(..., Any)` returns the serialized text unchanged. + +By default, dictionaries accept only exact string keys. Pass `strict_json_dict=False` to allow `int`, `float`, `bool`, and `None` keys. These keys are converted to JSON property names, so their original types are not preserved. Different Python keys may also produce duplicate JSON property names; the serialized text retains the duplicates, but `from_string` retains only the last value. Other key types, including `date` and `datetime`, raise `TypeError` in both modes. + +> 👀 There are two additional round-trip limitations: +> +> - `NaN` must be compared semantically because `NaN != NaN`. The sign of `-0.0` is preserved. +> - Datetime serialization preserves calendar and time fields, microseconds, and UTC offsets that are zero or at least one second in magnitude. A nonzero subsecond offset becomes zero during deserialization without changing the wall time. ISO strings do not preserve `fold` or a `tzinfo` object's identity or custom name. From 2466867bf1dfd29b86a478bd8ca496d189f1ebed 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, 24 Aug 2026 20:11:59 +0300 Subject: [PATCH 19/24] Bump version to 0.0.15 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ab42346..56ed4d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "simtypes" -version = "0.0.14" +version = "0.0.15" authors = [{ name = "Evgeniy Blinov", email = "zheni-b@yandex.ru" }] description = 'Type checking in runtime without stupid games' readme = "README.md" From b4bc06ee6904d39dbfe66fd0d00341caa276bd1c 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, 24 Aug 2026 21:00:53 +0300 Subject: [PATCH 20/24] Add pip upgrade step for Python 3.15 in CI --- .github/workflows/lint.yml | 5 +++++ .github/workflows/tests_and_coverage.yml | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f5bb36c..a85bf7c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -18,6 +18,11 @@ jobs: with: python-version: ${{ matrix.python-version }} + - name: Upgrade pip for Python 3.15 + if: startsWith(matrix.python-version, '3.15') + shell: bash + run: python -m pip install "pip>=26.1" + - name: Cache pip dependencies uses: actions/cache@v4 with: diff --git a/.github/workflows/tests_and_coverage.yml b/.github/workflows/tests_and_coverage.yml index 553148e..432271f 100644 --- a/.github/workflows/tests_and_coverage.yml +++ b/.github/workflows/tests_and_coverage.yml @@ -18,6 +18,11 @@ jobs: with: python-version: ${{ matrix.python-version }} + - name: Upgrade pip for Python 3.15 + if: startsWith(matrix.python-version, '3.15') + shell: bash + run: python -m pip install "pip>=26.1" + - name: Install the library shell: bash run: pip install . From e045a4ff6e8b0fdac7bc833d93eddea21b60d5fe 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, 24 Aug 2026 21:18:34 +0300 Subject: [PATCH 21/24] Update Python 3.15 preview to rc.1 in CI matrix --- .github/workflows/lint.yml | 2 +- .github/workflows/tests_and_coverage.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a85bf7c..5fea43a 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -8,7 +8,7 @@ jobs: strategy: matrix: python-version: - ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "3.14t", "3.15.0-alpha.1"] + ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "3.14t", "3.15.0-rc.1"] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/tests_and_coverage.yml b/.github/workflows/tests_and_coverage.yml index 432271f..d1f6cc2 100644 --- a/.github/workflows/tests_and_coverage.yml +++ b/.github/workflows/tests_and_coverage.yml @@ -9,7 +9,7 @@ jobs: matrix: os: [macos-latest, ubuntu-latest, windows-latest] python-version: - ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "3.14t", "3.15.0-alpha.1"] + ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "3.14t", "3.15.0-rc.1"] steps: - uses: actions/checkout@v4 From cc711e67b58c985b97409ab35aa4445aa7a377b4 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, 24 Aug 2026 21:50:00 +0300 Subject: [PATCH 22/24] Update README to clarify subsecond offset behavior --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e99e061..8908955 100644 --- a/README.md +++ b/README.md @@ -351,4 +351,4 @@ By default, dictionaries accept only exact string keys. Pass `strict_json_dict=F > 👀 There are two additional round-trip limitations: > > - `NaN` must be compared semantically because `NaN != NaN`. The sign of `-0.0` is preserved. -> - Datetime serialization preserves calendar and time fields, microseconds, and UTC offsets that are zero or at least one second in magnitude. A nonzero subsecond offset becomes zero during deserialization without changing the wall time. ISO strings do not preserve `fold` or a `tzinfo` object's identity or custom name. +> - Datetime serialization preserves calendar and time fields, microseconds, and the exact UTC offset. `from_string` follows `datetime.fromisoformat`: current CPython releases preserve subsecond offsets, while versions affected by [CPython issue 152079](https://github.com/python/cpython/issues/152079) normalize them to zero. ISO strings do not preserve `fold` or a `tzinfo` object's identity or custom name. From 41585cfccbe94fbf76b0db231376bf85fe6661f7 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, 24 Aug 2026 21:50:15 +0300 Subject: [PATCH 23/24] Document CPython version-dependent behavior of subsecond UTC offsets --- docs/plans/2.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/plans/2.md b/docs/plans/2.md index 238d5f5..81ec7fe 100644 --- a/docs/plans/2.md +++ b/docs/plans/2.md @@ -51,7 +51,7 @@ from_string(to_string(value), expected_type) ≈ value - два значения `NaN` считаются эквивалентными; - для нулевых `float` сохраняется знак; - для `datetime` сохраняются календарные поля, время и микросекунды; -- UTC offset сохраняется, кроме ненулевых значений меньше одной секунды по модулю: `datetime.fromisoformat()` нормализует их в нулевой offset; +- UTC offset точно сохраняется в сериализованном тексте; при десериализации ненулевые значения меньше одной секунды по модулю сохраняются в актуальных версиях CPython, но нормализуются в ноль в версиях, затронутых CPython issue 152079; - identity объекта `tzinfo`, его имя и значение `fold` форматом ISO не сохраняются. При `expected_type=Any` round-trip гарантируется только тогда, когда исходное Python-значение имеет точный тип `str`. Для любого другого значения `from_string(..., Any)` возвращает сериализованный текст как строку, а не исходное типизированное значение. @@ -136,7 +136,7 @@ hypothesis>=6.113.0 В `tests/units/test_to_string.py` определить: - `_assert_round_trip_equivalent(expected, actual)` — рекурсивно проверяет точные типы, `NaN`, знак нуля и установленную семантику `datetime`; -- `round_trippable_scalar_cases` — пары из значения и соответствующего точного типа для всех scalar-категорий; `datetime` с ненулевым UTC offset меньше одной секунды по модулю исключаются и проверяются отдельным тестом ограничения; +- `round_trippable_scalar_cases` — пары из значения и соответствующего точного типа для всех scalar-категорий; `datetime` с ненулевым UTC offset меньше одной секунды по модулю исключаются из общей стратегии из-за различий между версиями CPython и проверяются отдельным тестом; - `recursive_round_trip_cases` — ограниченные по размеру ациклические деревья из списков, кортежей и словарей вместе с точной параметризованной аннотацией; - `string_key_dict_cases` — словари только со строковыми ключами и соответствующим точным типом; - `json_native_collection_cases` — JSON-native коллекции для сравнения с `json.dumps`. @@ -183,9 +183,9 @@ hypothesis>=6.113.0 | Название теста | Суть фиксируемого поведения | Цель | Подготовка | Проверки | |---|---|---|---|---| -| `test_round_trippable_scalar_values_serialize_canonically_and_round_trip` | Все scalar-категории в round-trippable-домене проходят сериализацию и обратную десериализацию | Проверить основной инвариант на широком диапазоне значений | Hypothesis: `round_trippable_scalar_cases`; `max_examples=200`, `deadline=None`. Стратегия исключает `datetime` с ненулевым UTC offset меньше одной секунды по модулю. Добавить явные examples для `None` с `None` и `type(None)`, `-0.0`, infinities, `NaN`, aware datetime и `fold=1` | `_assert_round_trip_equivalent` подтверждает значение, точный тип, семантику `NaN`, знак нуля и установленную семантику datetime | +| `test_round_trippable_scalar_values_serialize_canonically_and_round_trip` | Все scalar-категории в round-trippable-домене проходят сериализацию и обратную десериализацию | Проверить основной инвариант на широком диапазоне значений | Hypothesis: `round_trippable_scalar_cases`; `max_examples=200`, `deadline=None`. Стратегия исключает `datetime` с ненулевым UTC offset меньше одной секунды по модулю из-за различий между версиями CPython. Добавить явные examples для `None` с `None` и `type(None)`, `-0.0`, infinities, `NaN`, aware datetime и `fold=1` | `_assert_round_trip_equivalent` подтверждает значение, точный тип, семантику `NaN`, знак нуля и установленную семантику datetime | | `test_datetime_round_trip_preserves_fields_and_offset_but_loses_tzinfo_identity_name_and_fold` | ISO round-trip сохраняет дату, время, микросекунды и offset, но теряет identity и имя `tzinfo`, а также `fold` | Явно покрыть заявленное ограничение datetime отдельным тестом | Детерминированный aware datetime с именованным fixed-offset `tzinfo`, микросекундами и `fold=1`; сериализация и `from_string(..., datetime)` | Календарные и временные поля и `utcoffset()` совпадают; точный тип остаётся `datetime`; восстановленный `tzinfo` не является исходным объектом и теряет исходное имя; `fold` восстановленного значения равен `0` | -| `test_datetime_serialization_preserves_subsecond_offset_but_round_trip_loses_it` | ISO-текст сохраняет ненулевой UTC offset меньше секунды по модулю, но `from_string` нормализует его в нулевой offset | Явно зафиксировать границу datetime round-trip-гарантии | Детерминированная параметризация положительных и отрицательных subsecond-offsets; сериализация и `from_string(..., datetime)` | Сериализованный текст совпадает с `datetime.isoformat()`, календарные и временные поля сохраняются, исходный subsecond-offset после десериализации становится нулевым | +| `test_datetime_subsecond_offset_serializes_exactly_and_matches_fromisoformat` | ISO-текст точно сохраняет ненулевой UTC offset меньше секунды по модулю, а `from_string` следует поведению `datetime.fromisoformat` текущей версии Python | Зафиксировать неизменный формат сериализации и различие десериализации между версиями CPython | Детерминированная параметризация положительных и отрицательных subsecond-offsets; сериализация, `from_string(..., datetime)` и прямой вызов `datetime.fromisoformat()` | Сериализованный текст совпадает с `datetime.isoformat()`, календарные и временные поля сохраняются, а восстановленный offset совпадает с результатом текущего runtime и равен либо исходному значению, либо нулю | | `test_recursive_collection_round_trip` | Поддерживаемые вложенные list/tuple/dict восстанавливают структуру и точные типы | Проверить рекурсивный инвариант | Hypothesis: `recursive_round_trip_cases`; `max_examples=200`, `deadline=None` | `_assert_round_trip_equivalent` подтверждает структуру, значения и exact-типы на всех уровнях | | `test_string_dict_key_round_trip` | Строгие словари со строковыми ключами восстанавливаются без потерь | Проверить полный безопасный домен словарей | Hypothesis: `string_key_dict_cases`; `max_examples=200`, `deadline=None` | Ключи остаются точными строками, значения и вложенные структуры эквивалентны исходным | | `test_round_trip_requires_precise_external_type` | Одинаковый JSON может означать разные вложенные типы | Зафиксировать необходимость хранить полный тип для `from_string`, но не передавать его в `to_string` | Детерминированная параметризация `list[date]`, `list[tuple[int, ...]]` и вложенных tuple/list с точной и стёртой аннотациями | С точной аннотацией исходное значение восстанавливается; с bare-контейнером вложенный тип меняется | @@ -213,7 +213,7 @@ hypothesis>=6.113.0 - пример `strict_json_dict=False`, в котором числовой ключ после десериализации становится строкой; - пояснение, что non-strict режим разрешает только нативно поддерживаемые `json.dumps` ключи `int`, `float`, `bool` и `None`, а `date` и `datetime` остаются неподдерживаемыми ключами; - ограничение `NaN` и используемое для него семантическое сравнение; -- явное ограничение datetime: ISO-представление сохраняет календарные и временные поля; UTC offset сохраняется, кроме ненулевых значений меньше одной секунды по модулю, которые `datetime.fromisoformat()` нормализует в нулевой offset; identity/имя `tzinfo` и `fold` не сохраняются; +- явное ограничение datetime: ISO-представление сохраняет календарные и временные поля и точный UTC offset; `from_string` следует `datetime.fromisoformat`, поэтому актуальные версии CPython сохраняют subsecond-offsets, а версии, затронутые CPython issue 152079, нормализуют их в ноль; identity/имя `tzinfo` и `fold` не сохраняются; - возможность появления одинаковых JSON property names в non-strict режиме; - поведение для неподдерживаемых типов и наследников поддерживаемых типов. From 647fa6b694284fbc5f74accbe3a1c33aa618f590 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, 24 Aug 2026 21:50:24 +0300 Subject: [PATCH 24/24] Update subsecond tz offset test to match fromisoformat behavior --- tests/units/test_to_string.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/units/test_to_string.py b/tests/units/test_to_string.py index 2cf36f8..e1709d8 100644 --- a/tests/units/test_to_string.py +++ b/tests/units/test_to_string.py @@ -784,10 +784,10 @@ def test_datetime_round_trip_preserves_fields_and_offset_but_loses_tzinfo_identi @pytest.mark.parametrize('offset_microseconds', [1, 999_999, -1, -999_999]) -def test_datetime_subsecond_offset_serializes_exactly_but_does_not_round_trip( +def test_datetime_subsecond_offset_serializes_exactly_and_matches_fromisoformat( offset_microseconds: int, ): - """Serialize nonzero subsecond UTC offsets exactly; from_string preserves wall time but sets the offset to zero.""" + """Serialize subsecond UTC offsets exactly and follow the runtime's fromisoformat behavior.""" value = datetime( 2026, 1, @@ -800,11 +800,14 @@ def test_datetime_subsecond_offset_serializes_exactly_but_does_not_round_trip( serialized = to_string(value) restored = from_string(serialized, datetime) + restored_by_runtime = datetime.fromisoformat(serialized) assert serialized == value.isoformat() + assert type(restored) is datetime assert restored.replace(tzinfo=None) == value.replace(tzinfo=None) assert value.utcoffset() == timedelta(microseconds=offset_microseconds) - assert restored.utcoffset() == timedelta(0) + assert restored.utcoffset() == restored_by_runtime.utcoffset() + assert restored.utcoffset() in (value.utcoffset(), timedelta(0)) @pytest.mark.parametrize(