Allow constructing iflators for dict->tuple for further args unwrap

This commit is contained in:
2025-08-19 16:51:52 +03:00
parent 9e3d4d0a25
commit c11a63c8a5
6 changed files with 130 additions and 15 deletions

View File

@@ -47,6 +47,7 @@ fn({'a': 1, 'b': 2, 'c': {'field1': {}, 'field2': '1.1', 'field3': None}})
- не проверяет типы generic-словарей, кортежей (реализация ожидается)
- пользовательские проверки типов должны быть реализованы через наследование и проверки в конструкторе
- опциональный `strict-mode`: выключение приведения базовых типов
- может генерировать кортежи верхнеуровневых объектов при наличии описания схемы (полезно при развертывании аргументов)
----
@@ -101,3 +102,10 @@ class A:
>>> {"a": [1, 1.1, "321"]}
<<< FieldValidationException, т.к. 1.1 не является int
```
### Tuple unwrap
```
fn = infl.schema_to_inflator(
(('a', int), TupleSchemaItem(Optional[list[int]], key_name='b', has_default=True, default=None)))
```
Создаёт `fn: (dict[str,Any]) -> tuple[int, Optional[list[int]]]: ...` (сигнатура остаётся `(dict[str,Any])->tuple`)

View File

@@ -1,6 +1,6 @@
[project]
name = "megasniff"
version = "0.2.1"
version = "0.2.2"
description = "Library for in-time codegened type validation"
authors = [
{ name = "nikto_b", email = "niktob560@yandex.ru" }

View File

@@ -7,7 +7,8 @@ from collections import defaultdict
from collections.abc import Callable
from dataclasses import dataclass
from types import NoneType, UnionType
from typing import Optional, get_origin, get_args, Union, Annotated, Literal, Sequence, List, Set, TypeAliasType
from typing import Optional, get_origin, get_args, Union, Annotated, Literal, Sequence, List, Set, TypeAliasType, \
OrderedDict
import jinja2
@@ -45,14 +46,17 @@ class SchemaInflatorGenerator:
templateLoader: jinja2.BaseLoader
templateEnv: jinja2.Environment
template: jinja2.Template
object_template: jinja2.Template
tuple_template: jinja2.Template
_strict_mode: bool
def __init__(self,
loader: Optional[jinja2.BaseLoader] = None,
strict_mode: bool = False,
*,
template_filename: str = 'inflator.jinja2'):
object_template_filename: str = 'inflator.jinja2',
tuple_template_filename: str = 'inflator_tuple.jinja2',
):
self._strict_mode = strict_mode
@@ -62,10 +66,11 @@ class SchemaInflatorGenerator:
self.templateLoader = loader
self.templateEnv = jinja2.Environment(loader=self.templateLoader)
self.template = self.templateEnv.get_template(template_filename)
self.object_template = self.templateEnv.get_template(object_template_filename)
self.tuple_template = self.templateEnv.get_template(tuple_template_filename)
def schema_to_inflator(self,
schema: type,
schema: type | Sequence[TupleSchemaItem | tuple[str, type]] | OrderedDict[str, type],
strict_mode_override: Optional[bool] = None,
from_type_override: Optional[type | TypeAliasType] = None
) -> Callable[[dict[str, Any]], Any]:
@@ -117,20 +122,46 @@ class SchemaInflatorGenerator:
strict_mode if is_builtin else False)
def _schema_to_inflator(self,
schema: type,
schema: type | Sequence[TupleSchemaItem | tuple[str, type]] | OrderedDict[str, type],
strict_mode_override: Optional[bool] = None,
from_type_override: Optional[type | TypeAliasType] = None,
*,
_funcname='inflate',
_namespace=None
_namespace=None,
) -> tuple[str, dict]:
if strict_mode_override is not None:
strict_mode = strict_mode_override
else:
strict_mode = self._strict_mode
template = self.object_template
mode = 'object'
if isinstance(schema, dict):
new_schema = []
for argname, argtype in schema.items():
new_schema.append((argname, argtype))
schema = new_schema
if isinstance(schema, collections.abc.Iterable):
template = self.tuple_template
mode = 'tuple'
new_schema = []
for t in schema:
if isinstance(t, TupleSchemaItem):
new_schema.append(t)
else:
new_schema.append(TupleSchemaItem(t[1], key_name=t[0]))
schema = new_schema
# Я это написал, оно пока работает, и я не собираюсь это упрощать, сорян
type_hints = get_kwargs_type_hints(schema)
if mode == 'object':
type_hints = get_kwargs_type_hints(schema)
else:
type_hints = {}
for i, t in enumerate(schema):
n = t.key_name or f'_arg_{i}'
type_hints[n] = t.schema
render_data = []
txt_segments = []
@@ -143,8 +174,10 @@ class SchemaInflatorGenerator:
if namespace.get(f'{_funcname}_tgt_type') is not None:
return '', namespace
namespace[f'{_funcname}_tgt_type'] = schema
namespace[utils.typename(schema)] = schema
if mode == 'object':
namespace[f'{_funcname}_tgt_type'] = schema
namespace[utils.typename(schema)] = schema
if from_type_override is not None:
namespace['_from_type'] = from_type_override
@@ -202,7 +235,7 @@ class SchemaInflatorGenerator:
else:
namespace[argt.__name__] = argt
convertor_functext = self.template.render(
convertor_functext = template.render(
funcname=_funcname,
conversions=render_data,
tgt_type=utils.typename(schema),

View File

@@ -0,0 +1,32 @@
{% set ns = namespace(retry_indent=0) %}
{% import "unwrap_type_data.jinja2" as unwrap_type_data %}
def {{funcname}}(from_data: {% if from_type is none %}dict[str, Any]{% else %}{{from_type}}{% endif %}) {% if tgt_type is not none %} -> tuple {% endif %}:
"""
{% for conv in conversions %}{{conv.argname}}:{{conv.typename}}, {% endfor %}
"""
from_data_keys = from_data.keys()
{% for conv in conversions %}
if '{{conv.argname}}' not in from_data_keys:
{% if conv.is_optional %}
{{conv.argname}} = {{conv.default_option}}
{% else %}
raise MissingFieldException('{{conv.argname}}', "{{conv.typename | replace('"', "'")}}")
{% endif %}
else:
conv_data = from_data['{{conv.argname}}']
if conv_data is None:
{% if not conv.allow_none %}
raise FieldValidationException('{{conv.argname}}', "{{conv.typename | replace('"', "'")}}", conv_data)
{% else %}
{{conv.argname}} = None
{% endif %}
else:
{{ unwrap_type_data.render_segment(conv.argname, conv.constrs, "conv_data", false) | indent(4*3) }}
{% endfor %}
return ({% for conv in conversions %}{{conv.argname}}, {% endfor %})

View File

@@ -1,6 +1,15 @@
import collections.abc
import dataclasses
import inspect
from typing import get_type_hints, Any, get_origin
from typing import get_type_hints, Any, get_origin, Iterable, Optional
@dataclasses.dataclass
class TupleSchemaItem:
schema: type
key_name: str
has_default: bool = False
default: Any = None
def is_typed_dict_type(tp: type) -> bool:
@@ -19,7 +28,7 @@ def get_kwargs_type_hints(obj: type) -> dict[str, Any]:
return get_type_hints(obj.__init__)
def get_field_default(cls: type[Any], field: str) -> tuple[bool, Any]:
def get_field_default(cls: type[Any] | Iterable[TupleSchemaItem], field: str) -> tuple[bool, Any]:
if dataclasses.is_dataclass(cls):
for f in dataclasses.fields(cls):
if f.name == field:
@@ -32,6 +41,12 @@ def get_field_default(cls: type[Any], field: str) -> tuple[bool, Any]:
# поле не объявлено в dataclass
return False, None
if isinstance(cls, collections.abc.Iterable):
for i, t in enumerate(cls):
if (t.key_name or f'_arg_{i}') == field:
return t.has_default, t.default
return False, None
sig = inspect.signature(cls.__init__)
params = list(sig.parameters.values())[1:]
@@ -53,6 +68,6 @@ def is_builtin_type(tp: type) -> bool:
def typename(tp: type) -> str:
if get_origin(tp) is None:
if get_origin(tp) is None and hasattr(tp, '__name__'):
return tp.__name__
return str(tp)

View File

@@ -0,0 +1,27 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
from src.megasniff.utils import TupleSchemaItem
from src.megasniff import SchemaInflatorGenerator
def test_basic_tuple():
infl = SchemaInflatorGenerator()
fn = infl.schema_to_inflator({'a': int, 'b': float, 'c': str, 'd': list[int]})
a = fn({'a': 42, 'b': 1.1, 'c': 123, 'd': []})
assert a[0] == 42
fn = infl.schema_to_inflator((('a', int), ('b', list[int])))
a = fn({'a': 42, 'b': ['1']})
assert a[1][0] == 1
fn = infl.schema_to_inflator(
(('a', int), TupleSchemaItem(Optional[list[int]], key_name='b', has_default=True, default=None)))
a = fn({'a': 42})
assert a[1] is None
assert a[0] == 42