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-словарей, кортежей (реализация ожидается) - не проверяет типы generic-словарей, кортежей (реализация ожидается)
- пользовательские проверки типов должны быть реализованы через наследование и проверки в конструкторе - пользовательские проверки типов должны быть реализованы через наследование и проверки в конструкторе
- опциональный `strict-mode`: выключение приведения базовых типов - опциональный `strict-mode`: выключение приведения базовых типов
- может генерировать кортежи верхнеуровневых объектов при наличии описания схемы (полезно при развертывании аргументов)
---- ----
@@ -101,3 +102,10 @@ class A:
>>> {"a": [1, 1.1, "321"]} >>> {"a": [1, 1.1, "321"]}
<<< FieldValidationException, т.к. 1.1 не является int <<< 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] [project]
name = "megasniff" name = "megasniff"
version = "0.2.1" version = "0.2.2"
description = "Library for in-time codegened type validation" description = "Library for in-time codegened type validation"
authors = [ authors = [
{ name = "nikto_b", email = "niktob560@yandex.ru" } { name = "nikto_b", email = "niktob560@yandex.ru" }

View File

@@ -7,7 +7,8 @@ from collections import defaultdict
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from types import NoneType, UnionType 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 import jinja2
@@ -45,14 +46,17 @@ class SchemaInflatorGenerator:
templateLoader: jinja2.BaseLoader templateLoader: jinja2.BaseLoader
templateEnv: jinja2.Environment templateEnv: jinja2.Environment
template: jinja2.Template object_template: jinja2.Template
tuple_template: jinja2.Template
_strict_mode: bool _strict_mode: bool
def __init__(self, def __init__(self,
loader: Optional[jinja2.BaseLoader] = None, loader: Optional[jinja2.BaseLoader] = None,
strict_mode: bool = False, 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 self._strict_mode = strict_mode
@@ -62,10 +66,11 @@ class SchemaInflatorGenerator:
self.templateLoader = loader self.templateLoader = loader
self.templateEnv = jinja2.Environment(loader=self.templateLoader) 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, def schema_to_inflator(self,
schema: type, schema: type | Sequence[TupleSchemaItem | tuple[str, type]] | OrderedDict[str, type],
strict_mode_override: Optional[bool] = None, strict_mode_override: Optional[bool] = None,
from_type_override: Optional[type | TypeAliasType] = None from_type_override: Optional[type | TypeAliasType] = None
) -> Callable[[dict[str, Any]], Any]: ) -> Callable[[dict[str, Any]], Any]:
@@ -117,20 +122,46 @@ class SchemaInflatorGenerator:
strict_mode if is_builtin else False) strict_mode if is_builtin else False)
def _schema_to_inflator(self, def _schema_to_inflator(self,
schema: type, schema: type | Sequence[TupleSchemaItem | tuple[str, type]] | OrderedDict[str, type],
strict_mode_override: Optional[bool] = None, strict_mode_override: Optional[bool] = None,
from_type_override: Optional[type | TypeAliasType] = None, from_type_override: Optional[type | TypeAliasType] = None,
*, *,
_funcname='inflate', _funcname='inflate',
_namespace=None _namespace=None,
) -> tuple[str, dict]: ) -> tuple[str, dict]:
if strict_mode_override is not None: if strict_mode_override is not None:
strict_mode = strict_mode_override strict_mode = strict_mode_override
else: else:
strict_mode = self._strict_mode 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
# Я это написал, оно пока работает, и я не собираюсь это упрощать, сорян # Я это написал, оно пока работает, и я не собираюсь это упрощать, сорян
if mode == 'object':
type_hints = get_kwargs_type_hints(schema) 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 = [] render_data = []
txt_segments = [] txt_segments = []
@@ -143,8 +174,10 @@ class SchemaInflatorGenerator:
if namespace.get(f'{_funcname}_tgt_type') is not None: if namespace.get(f'{_funcname}_tgt_type') is not None:
return '', namespace return '', namespace
if mode == 'object':
namespace[f'{_funcname}_tgt_type'] = schema namespace[f'{_funcname}_tgt_type'] = schema
namespace[utils.typename(schema)] = schema namespace[utils.typename(schema)] = schema
if from_type_override is not None: if from_type_override is not None:
namespace['_from_type'] = from_type_override namespace['_from_type'] = from_type_override
@@ -202,7 +235,7 @@ class SchemaInflatorGenerator:
else: else:
namespace[argt.__name__] = argt namespace[argt.__name__] = argt
convertor_functext = self.template.render( convertor_functext = template.render(
funcname=_funcname, funcname=_funcname,
conversions=render_data, conversions=render_data,
tgt_type=utils.typename(schema), 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 dataclasses
import inspect 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: 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__) 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): if dataclasses.is_dataclass(cls):
for f in dataclasses.fields(cls): for f in dataclasses.fields(cls):
if f.name == field: if f.name == field:
@@ -32,6 +41,12 @@ def get_field_default(cls: type[Any], field: str) -> tuple[bool, Any]:
# поле не объявлено в dataclass # поле не объявлено в dataclass
return False, None 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__) sig = inspect.signature(cls.__init__)
params = list(sig.parameters.values())[1:] params = list(sig.parameters.values())[1:]
@@ -53,6 +68,6 @@ def is_builtin_type(tp: type) -> bool:
def typename(tp: type) -> str: 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 tp.__name__
return str(tp) 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