Create toggle for strict-mode inflate
This commit is contained in:
55
README.md
55
README.md
@@ -1,12 +1,16 @@
|
|||||||
# megasniff
|
# megasniff
|
||||||
|
|
||||||
### Автоматическая валидация данных по схеме и сборка объекта в одном флаконе
|
### Автоматическая валидация данных по схеме и сборка объекта в одном флаконе
|
||||||
|
|
||||||
#### Как применять:
|
#### Как применять:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# 1. Объявляем схемы
|
# 1. Объявляем схемы
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
class SomeSchema1:
|
class SomeSchema1:
|
||||||
a: int
|
a: int
|
||||||
@@ -19,40 +23,81 @@ class SomeSchema2(typing.TypedDict):
|
|||||||
field2: float
|
field2: float
|
||||||
field3: typing.Optional[SomeSchema1]
|
field3: typing.Optional[SomeSchema1]
|
||||||
|
|
||||||
|
|
||||||
# 2. Генерируем метод для валидации и сборки
|
# 2. Генерируем метод для валидации и сборки
|
||||||
import megasniff
|
import megasniff
|
||||||
|
|
||||||
infl = megasniff.SchemaInflatorGenerator()
|
infl = megasniff.SchemaInflatorGenerator()
|
||||||
fn = infl.schema_to_generator(SomeSchema1)
|
fn = infl.schema_to_inflator(SomeSchema1)
|
||||||
|
|
||||||
# 3. Проверяем что всё работает
|
# 3. Проверяем что всё работает
|
||||||
|
|
||||||
fn({'a': 1, 'b': 2, 'c': {'field1': {}, 'field2': '1.1'}})
|
fn({'a': 1, 'b': 2, 'c': {'field1': {}, 'field2': '1.1', 'field3': None}})
|
||||||
# SomeSchema1(a=1, b=2.0, c={'field1': {}, 'field2': 1.1, 'field3': None})
|
# SomeSchema1(a=1, b=2.0, c={'field1': {}, 'field2': 1.1, 'field3': None})
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Особенности работы:
|
Особенности работы:
|
||||||
|
|
||||||
- поддерживает циклические зависимости
|
- поддерживает циклические зависимости
|
||||||
- проверяем `Union`-типы через ретрай на выбросе исключения
|
- проверяет `Union`-типы через ретрай на выбросе исключения
|
||||||
- по умолчанию использует готовый щаблон для кодогенерации и исполняет его по запросу, требуется особое внимание к сохранности данного шаблона
|
- по умолчанию использует готовый щаблон для кодогенерации и исполняет его по запросу, требуется особое внимание к
|
||||||
- не проверяет типы списков, словарей, кортежей (реализация ожидается)
|
сохранности данного шаблона
|
||||||
|
- проверяет типы списков, может приводить списки к множествам
|
||||||
|
- не проверяет типы generic-словарей, кортежей (реализация ожидается)
|
||||||
- пользовательские проверки типов должны быть реализованы через наследование и проверки в конструкторе
|
- пользовательские проверки типов должны быть реализованы через наследование и проверки в конструкторе
|
||||||
|
- опциональный `strict-mode`: выключение приведения базовых типов
|
||||||
|
|
||||||
----
|
----
|
||||||
|
|
||||||
### Как установить:
|
### Как установить:
|
||||||
|
|
||||||
#### [uv](https://docs.astral.sh/uv/concepts/projects/dependencies/#dependency-sources):
|
#### [uv](https://docs.astral.sh/uv/concepts/projects/dependencies/#dependency-sources):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv add megasniff --index sniff_index=https://git.nikto-b.ru/api/packages/nikto_b/pypi/simple
|
uv add megasniff --index sniff_index=https://git.nikto-b.ru/api/packages/nikto_b/pypi/simple
|
||||||
```
|
```
|
||||||
|
|
||||||
#### [poetry](https://python-poetry.org/docs/repositories/#private-repository-example):
|
#### [poetry](https://python-poetry.org/docs/repositories/#private-repository-example):
|
||||||
|
|
||||||
1. Добавить репозиторий в `pyproject.toml`
|
1. Добавить репозиторий в `pyproject.toml`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
poetry source add --priority=supplemental sniff_index https://git.nikto-b.ru/api/packages/nikto_b/pypi/simple
|
poetry source add --priority=supplemental sniff_index https://git.nikto-b.ru/api/packages/nikto_b/pypi/simple
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Поставить пакет
|
2. Поставить пакет
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
poetry add --source sniff_index megasniff
|
poetry add --source sniff_index megasniff
|
||||||
```
|
```
|
||||||
|
|
||||||
|
----
|
||||||
|
|
||||||
|
### Strict-mode:
|
||||||
|
|
||||||
|
#### Strict-mode off:
|
||||||
|
|
||||||
|
```
|
||||||
|
@dataclass
|
||||||
|
class A:
|
||||||
|
a: list[int]
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
>>> {"a": [1, 1.1, "321"]}
|
||||||
|
<<< A(a=[1, 1, 321])
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Strict-mode on:
|
||||||
|
|
||||||
|
```
|
||||||
|
@dataclass
|
||||||
|
class A:
|
||||||
|
a: list[int]
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
>>> {"a": [1, 1.1, "321"]}
|
||||||
|
<<< FieldValidationException, т.к. 1.1 не является int
|
||||||
|
```
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class CSchema:
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
infl = SchemaInflatorGenerator()
|
infl = SchemaInflatorGenerator(strict_mode=True)
|
||||||
fn = infl.schema_to_inflator(ASchema)
|
fn = infl.schema_to_inflator(ASchema)
|
||||||
# print(t)
|
# print(t)
|
||||||
# print(n)
|
# print(n)
|
||||||
@@ -40,7 +40,7 @@ def main():
|
|||||||
# d = {'a': 1, 'b': 1, 'c': 0, 'bs': {'a': 1, 'b': 2, 'c': 3, 'd': {'a': 1, 'b': 2.1, 'bs': None}}}
|
# d = {'a': 1, 'b': 1, 'c': 0, 'bs': {'a': 1, 'b': 2, 'c': 3, 'd': {'a': 1, 'b': 2.1, 'bs': None}}}
|
||||||
# d = {'a': 2, 'b': 2, 'bs': {'a': 2, 'b': 'a', 'c': 0, 'd': {'a': 2, 'b': 2}}}
|
# d = {'a': 2, 'b': 2, 'bs': {'a': 2, 'b': 'a', 'c': 0, 'd': {'a': 2, 'b': 2}}}
|
||||||
# d = {'l': ['1', {'a': 42, 'b': 1}]}
|
# d = {'l': ['1', {'a': 42, 'b': 1}]}
|
||||||
d = {'a': 2, 'b': 2, 'bs': None}
|
d = {'a': 2, 'b': '2', 'bs': None}
|
||||||
try:
|
try:
|
||||||
o = fn(d)
|
o = fn(d)
|
||||||
print(o)
|
print(o)
|
||||||
|
|||||||
Binary file not shown.
@@ -21,6 +21,7 @@ class TypeRenderData:
|
|||||||
allow_none: bool
|
allow_none: bool
|
||||||
is_list: bool
|
is_list: bool
|
||||||
is_union: bool
|
is_union: bool
|
||||||
|
is_strict: bool
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -45,11 +46,16 @@ class SchemaInflatorGenerator:
|
|||||||
templateEnv: jinja2.Environment
|
templateEnv: jinja2.Environment
|
||||||
|
|
||||||
template: jinja2.Template
|
template: jinja2.Template
|
||||||
|
_strict_mode: bool
|
||||||
|
|
||||||
def __init__(self,
|
def __init__(self,
|
||||||
loader: Optional[jinja2.BaseLoader] = None,
|
loader: Optional[jinja2.BaseLoader] = None,
|
||||||
|
strict_mode: bool = False,
|
||||||
|
*,
|
||||||
template_filename: str = 'inflator.jinja2'):
|
template_filename: str = 'inflator.jinja2'):
|
||||||
|
|
||||||
|
self._strict_mode = strict_mode
|
||||||
|
|
||||||
if loader is None:
|
if loader is None:
|
||||||
template_path = importlib.resources.files('megasniff.templates')
|
template_path = importlib.resources.files('megasniff.templates')
|
||||||
loader = jinja2.FileSystemLoader(str(template_path))
|
loader = jinja2.FileSystemLoader(str(template_path))
|
||||||
@@ -58,15 +64,19 @@ class SchemaInflatorGenerator:
|
|||||||
self.templateEnv = jinja2.Environment(loader=self.templateLoader)
|
self.templateEnv = jinja2.Environment(loader=self.templateLoader)
|
||||||
self.template = self.templateEnv.get_template(template_filename)
|
self.template = self.templateEnv.get_template(template_filename)
|
||||||
|
|
||||||
def schema_to_inflator(self, schema: type) -> Callable[[dict[str, Any]], Any]:
|
def schema_to_inflator(self,
|
||||||
txt, namespace = self._schema_to_inflator(schema, _funcname='inflate')
|
schema: type,
|
||||||
|
strict_mode_override: Optional[bool] = None) -> Callable[[dict[str, Any]], Any]:
|
||||||
|
txt, namespace = self._schema_to_inflator(schema, _funcname='inflate',
|
||||||
|
strict_mode_override=strict_mode_override)
|
||||||
imports = ('from typing import Any\n'
|
imports = ('from typing import Any\n'
|
||||||
'from megasniff.exceptions import MissingFieldException, FieldValidationException\n')
|
'from megasniff.exceptions import MissingFieldException, FieldValidationException\n')
|
||||||
txt = imports + '\n' + txt
|
txt = imports + '\n' + txt
|
||||||
|
print(txt)
|
||||||
exec(txt, namespace)
|
exec(txt, namespace)
|
||||||
return namespace['inflate']
|
return namespace['inflate']
|
||||||
|
|
||||||
def _unwrap_typeref(self, t: type) -> TypeRenderData:
|
def _unwrap_typeref(self, t: type, strict_mode: bool) -> TypeRenderData:
|
||||||
type_origin = get_origin(t)
|
type_origin = get_origin(t)
|
||||||
allow_none = False
|
allow_none = False
|
||||||
argtypes = t,
|
argtypes = t,
|
||||||
@@ -81,11 +91,11 @@ class SchemaInflatorGenerator:
|
|||||||
is_union = len(argtypes) > 1
|
is_union = len(argtypes) > 1
|
||||||
|
|
||||||
if is_union:
|
if is_union:
|
||||||
typerefs = list(map(lambda x: self._unwrap_typeref(x), argtypes))
|
typerefs = list(map(lambda x: self._unwrap_typeref(x, strict_mode), argtypes))
|
||||||
return TypeRenderData(typerefs, allow_none, False, True)
|
return TypeRenderData(typerefs, allow_none, False, True, False)
|
||||||
elif type_origin in [list, set]:
|
elif type_origin in [list, set]:
|
||||||
rd = self._unwrap_typeref(argtypes[0])
|
rd = self._unwrap_typeref(argtypes[0], strict_mode)
|
||||||
return IterableTypeRenderData(rd, allow_none, True, False, type_origin.__name__)
|
return IterableTypeRenderData(rd, allow_none, True, False, False, type_origin.__name__)
|
||||||
else:
|
else:
|
||||||
t = argtypes[0]
|
t = argtypes[0]
|
||||||
|
|
||||||
@@ -94,13 +104,23 @@ class SchemaInflatorGenerator:
|
|||||||
t = type_origin or t
|
t = type_origin or t
|
||||||
|
|
||||||
is_builtin = is_builtin_type(t)
|
is_builtin = is_builtin_type(t)
|
||||||
return TypeRenderData(t.__name__ if is_builtin else f'inflate_{t.__name__}', allow_none, is_list, False)
|
return TypeRenderData(t.__name__ if is_builtin else f'inflate_{t.__name__}',
|
||||||
|
allow_none,
|
||||||
|
is_list,
|
||||||
|
False,
|
||||||
|
strict_mode if is_builtin else False)
|
||||||
|
|
||||||
def _schema_to_inflator(self,
|
def _schema_to_inflator(self,
|
||||||
schema: type,
|
schema: type,
|
||||||
|
strict_mode_override: Optional[bool] = None,
|
||||||
*,
|
*,
|
||||||
_funcname='inflate',
|
_funcname='inflate',
|
||||||
_namespace=None) -> tuple[str, dict]:
|
_namespace=None) -> tuple[str, dict]:
|
||||||
|
if strict_mode_override is not None:
|
||||||
|
strict_mode = strict_mode_override
|
||||||
|
else:
|
||||||
|
strict_mode = self._strict_mode
|
||||||
|
|
||||||
# Я это написал, оно пока работает, и я не собираюсь это упрощать, сорян
|
# Я это написал, оно пока работает, и я не собираюсь это упрощать, сорян
|
||||||
type_hints = get_kwargs_type_hints(schema)
|
type_hints = get_kwargs_type_hints(schema)
|
||||||
render_data = []
|
render_data = []
|
||||||
@@ -123,7 +143,7 @@ class SchemaInflatorGenerator:
|
|||||||
|
|
||||||
has_default, default_option = get_field_default(schema, argname)
|
has_default, default_option = get_field_default(schema, argname)
|
||||||
|
|
||||||
typeref = self._unwrap_typeref(argtype)
|
typeref = self._unwrap_typeref(argtype, strict_mode)
|
||||||
|
|
||||||
argtypes = argtype,
|
argtypes = argtype,
|
||||||
allow_none = False
|
allow_none = False
|
||||||
@@ -163,7 +183,8 @@ class SchemaInflatorGenerator:
|
|||||||
if argt.__name__ not in namespace.keys():
|
if argt.__name__ not in namespace.keys():
|
||||||
t, n = self._schema_to_inflator(argt,
|
t, n = self._schema_to_inflator(argt,
|
||||||
_funcname=f'inflate_{argt.__name__}',
|
_funcname=f'inflate_{argt.__name__}',
|
||||||
_namespace=namespace)
|
_namespace=namespace,
|
||||||
|
strict_mode_override=strict_mode_override)
|
||||||
namespace |= n
|
namespace |= n
|
||||||
txt_segments.append(t)
|
txt_segments.append(t)
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ def {{funcname}}(from_data: dict[str, Any]):
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
else:
|
else:
|
||||||
|
|
||||||
{{ unwrap_type_data.render_segment(conv.argname, conv.constrs, "conv_data") | indent(4*3) }}
|
{{ unwrap_type_data.render_segment(conv.argname, conv.constrs, "conv_data", false) | indent(4*3) }}
|
||||||
|
|
||||||
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
if not isinstance({{conv_data}}, list):
|
if not isinstance({{conv_data}}, list):
|
||||||
raise FieldValidationException('{{argname}}', "list", conv_data, [])
|
raise FieldValidationException('{{argname}}', "list", conv_data, [])
|
||||||
for item in {{conv_data}}:
|
for item in {{conv_data}}:
|
||||||
{{ render_segment("_" + argname, typedef, "item") | indent(4) }}
|
{{ render_segment("_" + argname, typedef, "item", false ) | indent(4) }}
|
||||||
{{argname}}.append(_{{argname}})
|
{{argname}}.append(_{{argname}})
|
||||||
{%- endset %}
|
{%- endset %}
|
||||||
{{out}}
|
{{out}}
|
||||||
@@ -18,7 +18,7 @@ for item in {{conv_data}}:
|
|||||||
all_conv_exceptions = []
|
all_conv_exceptions = []
|
||||||
{% for union_type in conv.typeref %}
|
{% for union_type in conv.typeref %}
|
||||||
{{ ' ' * ns.retry_indent }}try:
|
{{ ' ' * ns.retry_indent }}try:
|
||||||
{{ render_segment(argname, union_type, conv_data) | indent((ns.retry_indent + 1) * 4) }}
|
{{ render_segment(argname, union_type, conv_data, false) | indent((ns.retry_indent + 1) * 4) }}
|
||||||
{{ ' ' * ns.retry_indent }}except Exception as e:
|
{{ ' ' * ns.retry_indent }}except Exception as e:
|
||||||
{{ ' ' * ns.retry_indent }} all_conv_exceptions.append(e)
|
{{ ' ' * ns.retry_indent }} all_conv_exceptions.append(e)
|
||||||
{% set ns.retry_indent = ns.retry_indent + 1 %}
|
{% set ns.retry_indent = ns.retry_indent + 1 %}
|
||||||
@@ -28,10 +28,14 @@ all_conv_exceptions = []
|
|||||||
{{out}}
|
{{out}}
|
||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
|
|
||||||
{% macro render_segment(argname, typeref, conv_data) -%}
|
{% macro render_segment(argname, typeref, conv_data, strict) -%}
|
||||||
{%- set out -%}
|
{%- set out -%}
|
||||||
|
|
||||||
{% if typeref is string %}
|
{% if typeref is string %}
|
||||||
|
{% if strict %}
|
||||||
|
if not isinstance({{conv_data}}, {{typeref}}):
|
||||||
|
raise FieldValidationException('{{argname}}', "{{typeref | replace('"', "'")}}", {{conv_data}}, [])
|
||||||
|
{% endif %}
|
||||||
{{argname}} = {{typeref}}({{conv_data}})
|
{{argname}} = {{typeref}}({{conv_data}})
|
||||||
|
|
||||||
{% elif typeref.is_union %}
|
{% elif typeref.is_union %}
|
||||||
@@ -42,7 +46,7 @@ all_conv_exceptions = []
|
|||||||
{{argname}} = {{typeref.iterable_type}}({{argname}})
|
{{argname}} = {{typeref.iterable_type}}({{argname}})
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
{{render_segment(argname, typeref.typeref, conv_data)}}
|
{{render_segment(argname, typeref.typeref, conv_data, typeref.is_strict)}}
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|||||||
75
tests/test_strict_mode.py
Normal file
75
tests/test_strict_mode.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from megasniff import SchemaInflatorGenerator
|
||||||
|
from megasniff.exceptions import FieldValidationException
|
||||||
|
|
||||||
|
|
||||||
|
def test_global_strict_mode_basic():
|
||||||
|
class A:
|
||||||
|
def __init__(self, a: int):
|
||||||
|
self.a = a
|
||||||
|
|
||||||
|
infl = SchemaInflatorGenerator(strict_mode=True)
|
||||||
|
fn = infl.schema_to_inflator(A)
|
||||||
|
a = fn({'a': 42})
|
||||||
|
|
||||||
|
assert a.a == 42
|
||||||
|
|
||||||
|
with pytest.raises(FieldValidationException):
|
||||||
|
fn({'a': 42.0})
|
||||||
|
|
||||||
|
|
||||||
|
def test_global_strict_mode_basic_override():
|
||||||
|
class A:
|
||||||
|
def __init__(self, a: int):
|
||||||
|
self.a = a
|
||||||
|
|
||||||
|
infl = SchemaInflatorGenerator(strict_mode=False)
|
||||||
|
fn = infl.schema_to_inflator(A, strict_mode_override=True)
|
||||||
|
a = fn({'a': 42})
|
||||||
|
|
||||||
|
assert a.a == 42
|
||||||
|
|
||||||
|
with pytest.raises(FieldValidationException):
|
||||||
|
fn({'a': 42.0})
|
||||||
|
|
||||||
|
|
||||||
|
def test_global_strict_mode_list():
|
||||||
|
@dataclass
|
||||||
|
class A:
|
||||||
|
a: list[int]
|
||||||
|
|
||||||
|
infl = SchemaInflatorGenerator(strict_mode=True)
|
||||||
|
fn = infl.schema_to_inflator(A)
|
||||||
|
a = fn({'a': [42]})
|
||||||
|
|
||||||
|
assert a.a == [42]
|
||||||
|
|
||||||
|
with pytest.raises(FieldValidationException):
|
||||||
|
fn({'a': [42.0, 42]})
|
||||||
|
|
||||||
|
|
||||||
|
def test_global_strict_mode_circular():
|
||||||
|
@dataclass
|
||||||
|
class A:
|
||||||
|
a: list[int]
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class B:
|
||||||
|
b: list[A | int]
|
||||||
|
|
||||||
|
infl = SchemaInflatorGenerator(strict_mode=True)
|
||||||
|
fn = infl.schema_to_inflator(B)
|
||||||
|
b = fn({'b': [{'a': []}, 42]})
|
||||||
|
|
||||||
|
assert len(b.b) == 2
|
||||||
|
assert isinstance(b.b[0], A)
|
||||||
|
assert isinstance(b.b[1], int)
|
||||||
|
|
||||||
|
with pytest.raises(FieldValidationException):
|
||||||
|
fn({'b': [42.0]})
|
||||||
|
|
||||||
|
with pytest.raises(FieldValidationException):
|
||||||
|
fn({'b': [{'a': [1.1]}]})
|
||||||
Reference in New Issue
Block a user