Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ print(check(InnerNoneType('key'), InnerNoneType('key')))
The library also provides basic deserialization. Conversion of strings into several basic types in various combinations is supported:

- `str` - any string can be interpreted as a `str` type.
- `None` or `type(None)` - the strings `"null"` and `"None"` are interpreted as `None`.
- `int` - any integers.
- `float` - any floating-point numbers, including infinities and [`NaN`](https://en.wikipedia.org/wiki/NaN).
- `bool` - the strings `"yes"`, `"True"`, and `"true"` are interpreted as `True`, while `"no"`, `"False"`, or `"false"` are interpreted as `False`.
Expand Down Expand Up @@ -223,6 +224,12 @@ print(from_string('I am the danger', str))
print(from_string('I am the danger', Any)) # Any is interpreted as a string.
#> "I am the danger"

# None
print(from_string('null', None))
#> None
print(from_string('None', type(None)))
#> None

# bools
print(from_string('yes', bool))
#> True
Expand Down
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`.
31 changes: 30 additions & 1 deletion simtypes/from_string.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down Expand Up @@ -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]

Expand Down
24 changes: 24 additions & 0 deletions tests/typing/test_from_string.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import pytest

from simtypes import from_string

try:
from typing import assert_type # type: ignore[attr-defined, unused-ignore]
except ImportError: # pragma: no cover
from typing_extensions import assert_type


@pytest.mark.mypy_testing
def test_none_deserialization_return_types() -> None:
"""
assert_type checks exact return types: None for None and type(None), and int for int.

Runtime checks confirm the None singleton for both None targets and 1 for int.
"""
bare_none_result = assert_type(from_string('null', None), None)
none_type_result = assert_type(from_string('None', type(None)), None)
int_result = assert_type(from_string('1', int), int)

assert bare_none_result is None
assert none_type_result is None
assert int_result == 1
47 changes: 39 additions & 8 deletions tests/units/test_from_string.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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():
Expand Down Expand Up @@ -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'}]
Expand All @@ -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])

Expand Down Expand Up @@ -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'})
Expand Down Expand Up @@ -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])

Expand Down Expand Up @@ -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"}]}
Expand Down Expand Up @@ -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]])

Expand All @@ -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):
Expand Down
Loading