From c11a63c8a5b25801fbe903a6d64ee0001b7e10e6 Mon Sep 17 00:00:00 2001 From: nikto_b Date: Tue, 19 Aug 2025 16:51:52 +0300 Subject: [PATCH] Allow constructing iflators for dict->tuple for further args unwrap --- README.md | 8 +++ pyproject.toml | 2 +- src/megasniff/inflator.py | 55 +++++++++++++++---- src/megasniff/templates/inflator_tuple.jinja2 | 32 +++++++++++ src/megasniff/utils.py | 21 ++++++- tests/test_tuple_inflate.py | 27 +++++++++ 6 files changed, 130 insertions(+), 15 deletions(-) create mode 100644 src/megasniff/templates/inflator_tuple.jinja2 create mode 100644 tests/test_tuple_inflate.py diff --git a/README.md b/README.md index 7f75a9b..55afca0 100644 --- a/README.md +++ b/README.md @@ -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`) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index ca0639d..bf2821e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" } diff --git a/src/megasniff/inflator.py b/src/megasniff/inflator.py index 42e5803..2ed478d 100644 --- a/src/megasniff/inflator.py +++ b/src/megasniff/inflator.py @@ -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), diff --git a/src/megasniff/templates/inflator_tuple.jinja2 b/src/megasniff/templates/inflator_tuple.jinja2 new file mode 100644 index 0000000..57f9ac8 --- /dev/null +++ b/src/megasniff/templates/inflator_tuple.jinja2 @@ -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 %}) diff --git a/src/megasniff/utils.py b/src/megasniff/utils.py index aaadab0..907bd4d 100644 --- a/src/megasniff/utils.py +++ b/src/megasniff/utils.py @@ -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) diff --git a/tests/test_tuple_inflate.py b/tests/test_tuple_inflate.py new file mode 100644 index 0000000..5f833ba --- /dev/null +++ b/tests/test_tuple_inflate.py @@ -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