Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
9120bb6
Add plan for supporting `None` deserialization in `from_string`
Aug 20, 2026
826c23e
Rename test_none_deserialization_types to
Aug 20, 2026
9367e71
Support None type in from_string conversion
Aug 20, 2026
e4f7ac9
Add None deserialization support to from_string
Aug 20, 2026
f640f7b
Document None type deserialization
Aug 20, 2026
47c6e0f
Add preview image to docs
Aug 21, 2026
fc61a1d
Add .hypothesis to .gitignore
Aug 21, 2026
25791f2
Add to_string serialization implementation plan
Aug 21, 2026
201f25d
Add hypothesis to dev requirements
Aug 22, 2026
dd15bc8
Update serializer plan to rely on json.dumps for key handling
Aug 22, 2026
8c925d0
Export NonRoundTrippableKeyError and to_string from simtypes
Aug 22, 2026
30efdf9
Add NonRoundTrippableKeyError exception type
Aug 22, 2026
1ebc09a
Add AGENTS.md to .gitignore
Aug 23, 2026
5ed7add
Add to_string utility for simtypes serialization
Aug 24, 2026
1f7da5b
Remove redundant `-> None` annotations from test functions
Aug 24, 2026
23ad6de
Add to_string tests
Aug 24, 2026
dffffac
Add mypy typing test for to_string return types
Aug 24, 2026
c5bf0c6
Document string serialization in README
Aug 24, 2026
2466867
Bump version to 0.0.15
Aug 24, 2026
b4bc06e
Add pip upgrade step for Python 3.15 in CI
Aug 24, 2026
e045a4f
Update Python 3.15 preview to rc.1 in CI matrix
Aug 24, 2026
cc711e6
Update README to clarify subsecond offset behavior
Aug 24, 2026
41585cf
Document CPython version-dependent behavior of subsecond UTC offsets
Aug 24, 2026
647fa6b
Update subsecond tz offset test to match fromisoformat behavior
Aug 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
7 changes: 6 additions & 1 deletion .github/workflows/tests_and_coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 .
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
__pycache__
.pytest_cache
.hypothesis
.DS_Store
test.py
*.egg-info
Expand All @@ -17,3 +18,4 @@ uv.lock
.ropeproject
node_modules
mutants
AGENTS.md
103 changes: 103 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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
Expand All @@ -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.
Binary file added docs/assets/preview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
49 changes: 49 additions & 0 deletions docs/plans/1.md
Original file line number Diff line number Diff line change
@@ -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`.
Loading
Loading