diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f5bb36c..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 @@ -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..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 @@ -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 . diff --git a/.gitignore b/.gitignore index 263acc3..37d142f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ __pycache__ .pytest_cache +.hypothesis .DS_Store test.py *.egg-info @@ -17,3 +18,4 @@ uv.lock .ropeproject node_modules mutants +AGENTS.md diff --git a/README.md b/README.md index 6ff0c11..8908955 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? @@ -182,6 +183,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 +225,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 @@ -249,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 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. diff --git a/docs/assets/preview.png b/docs/assets/preview.png new file mode 100644 index 0000000..3db8f6e Binary files /dev/null and b/docs/assets/preview.png differ diff --git a/docs/plans/1.md b/docs/plans/1.md new file mode 100644 index 0000000..7905756 --- /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_return_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`. diff --git a/docs/plans/2.md b/docs/plans/2.md new file mode 100644 index 0000000..81ec7fe --- /dev/null +++ b/docs/plans/2.md @@ -0,0 +1,231 @@ +# Добавление `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: + ... +``` + +В `simtypes/errors.py` добавить: + +```python +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 точно сохраняется в сериализованном тексте; при десериализации ненулевые значения меньше одной секунды по модулю сохраняются в актуальных версиях CPython, но нормализуются в ноль в версиях, затронутых CPython issue 152079; +- 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` приводят к `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`; +- числовые, boolean и `None`-ключи преобразуются в те же имена JSON-свойств, которые создаёт `json.dumps`; +- `date` и `datetime` не разрешаются в позиции ключа: non-strict режим не расширяет набор ключей за пределы типов, которые `json.dumps` поддерживает нативно; +- канонические коллизии не отклоняются: сериализатор сохраняет порядок элементов и может создать несколько одинаковых JSON property names; +- после рекурсивной проверки типов коллекция целиком кодируется через `json.dumps`, который сохраняет порядок и канонические коллизии нативно поддерживаемых ключей. + +Ключи неподдерживаемых типов, включая `date`, `datetime`, `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.dumps`. Вложенные `date` и `datetime` передавать стандартному encoder через `default`, возвращающий `isoformat()`. Проверки типов выполнять по точному `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 меньше одной секунды по модулю исключаются из общей стратегии из-за различий между версиями CPython и проверяются отдельным тестом; +- `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` | Нативно поддерживаемые JSON значения других типов запрещены в позиции ключа строгого словаря | Проверить защиту от незаметного изменения типа | Детерминированная параметризация ключей точных типов `int`, `float`, `bool` и `NoneType` | Выбрасывается `NonRoundTrippableKeyError`, являющийся `TypeError`; сообщение содержит repr ключа, имя типа и способ отключить проверку | +| `test_strict_json_dict_applies_recursively` | Политика проверяет вложенные словари | Не допустить обхода ограничения через nesting | Детерминированные структуры с допустимым внешним словарём и запрещённым ключом на разных уровнях | Для каждого уровня выбрасывается `NonRoundTrippableKeyError` с данными фактического ключа | +| `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 типов | Детерминированная параметризация точных `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.` | + +### Ошибки и публичный экспорт + +| Название теста | Суть фиксируемого поведения | Цель | Подготовка | Проверки | +|---|---|---|---|---| +| `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 меньше одной секунды по модулю из-за различий между версиями 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_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-контейнером вложенный тип меняется | +| `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`, в котором числовой ключ после десериализации становится строкой; +- пояснение, что non-strict режим разрешает только нативно поддерживаемые `json.dumps` ключи `int`, `float`, `bool` и `None`, а `date` и `datetime` остаются неподдерживаемыми ключами; +- ограничение `NaN` и используемое для него семантическое сравнение; +- явное ограничение datetime: ISO-представление сохраняет календарные и временные поля и точный UTC offset; `from_string` следует `datetime.fromisoformat`, поэтому актуальные версии CPython сохраняют subsecond-offsets, а версии, затронутые CPython issue 152079, нормализуют их в ноль; 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-проверки, линтер и обе проверки покрытия проходят успешно. 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" 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 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, 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): + ... 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] 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) 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 new file mode 100644 index 0000000..2d56a68 --- /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(): + """ + 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/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) 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): diff --git a/tests/units/test_to_string.py b/tests/units/test_to_string.py new file mode 100644 index 0000000..e1709d8 --- /dev/null +++ b/tests/units/test_to_string.py @@ -0,0 +1,1263 @@ +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_and_matches_fromisoformat( + offset_microseconds: int, +): + """Serialize subsecond UTC offsets exactly and follow the runtime's fromisoformat behavior.""" + value = datetime( + 2026, + 1, + 22, + 3, + 4, + 5, + tzinfo=timezone(timedelta(microseconds=offset_microseconds)), + ) + + 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() == restored_by_runtime.utcoffset() + assert restored.utcoffset() in (value.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