Compare commits

32 Commits

Author SHA1 Message Date
89c4bcae90 Add flat types deflator tests 2025-10-17 00:26:10 +03:00
9775bc2cc6 Fix dict body validation x2 2025-10-16 22:19:42 +03:00
4068af462b Experimental support of a file-based inflators/deflators 2025-10-16 21:01:02 +03:00
d4d7a68d7a Fix dict body validation 2025-10-16 20:51:19 +03:00
b724e3c5dd Hotfix typename escape 2025-09-14 01:54:06 +03:00
4b77eb4217 Fix deflator and typename item types escaping 2025-09-14 01:51:41 +03:00
3aae5cf2d2 Escape deflaters names, extend signatures to allow root UnionTypes 2025-08-29 02:29:24 +03:00
8b29b941af Bump version 2025-08-29 01:29:24 +03:00
ebc296a270 Update README.md 2025-08-29 01:29:04 +03:00
de6362fa1d Create deflator strict mode and explicit casts flags with tests and default universal fallback unwrapper 2025-08-29 01:20:27 +03:00
51817784a3 Create basic deflator tests 2025-08-29 00:40:14 +03:00
cc77cc7012 Create basic deflator generator 2025-08-29 00:34:14 +03:00
36e343d3bc Make argnames escape 2025-08-20 21:59:46 +03:00
0786fc600a Fix default string option rendering 2025-08-20 03:08:37 +03:00
b11266990b Add store_sources option that stores rendered source in a __megasniff_sources__ property 2025-08-20 00:33:09 +03:00
c11a63c8a5 Allow constructing iflators for dict->tuple for further args unwrap 2025-08-19 16:51:52 +03:00
9e3d4d0a25 Add signature generation 2025-07-17 01:19:44 +03:00
9fc218e556 Clean __pycache__ 2025-07-14 17:04:31 +03:00
f8cacf9319 Bump version 2025-07-14 16:59:03 +03:00
9f54115160 Create toggle for strict-mode inflate 2025-07-14 16:54:34 +03:00
bc6acb099f Fix recursive union-iterable-*-types codegen 2025-07-14 16:27:55 +03:00
897eccd8d1 Remove lookup_table from inflator generated code, rename generating func 2025-07-12 05:50:56 +03:00
aee6dcf3d3 Extract complex type creation into separate template 2025-07-12 04:16:19 +03:00
1994eaab0d Add custom exceptions, simplify generation template 2025-07-12 02:37:54 +03:00
ed5f975e87 Remove excess print from SchemaInflatorGenerator 2025-07-12 02:22:35 +03:00
5b4eba5190 Split optional-provided field=something and typing.Optional=@nullable field options, add pytest 2025-07-12 02:16:17 +03:00
8a25d234c8 Add install steps 2025-07-12 01:14:30 +03:00
aac0a97101 Обновить README.md 2025-07-12 01:03:01 +03:00
b63eee8740 Bump version 2025-07-12 00:51:22 +03:00
0a471729e7 Filter import * from megasniff fields 2025-07-12 00:51:05 +03:00
bacb1319aa Fix default templates import path 2025-07-12 00:49:20 +03:00
8b70e83843 Fix package license 2025-07-12 00:49:07 +03:00
28 changed files with 1911 additions and 158 deletions

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/
**/__pycache__
*.py[cod]
*$py.class

125
README.md
View File

@@ -1,2 +1,127 @@
# megasniff
### Автоматическая валидация данных по схеме, сборка и разборка объекта в одном флаконе
#### Как применять:
```python
# 1. Объявляем схемы
from __future__ import annotations
import dataclasses
import typing
@dataclasses.dataclass
class SomeSchema1:
a: int
b: float | str
c: SomeSchema2 | str | None
@dataclasses.dataclass
class SomeSchema2:
field1: dict
field2: float
field3: typing.Optional[SomeSchema1]
# 2. Генерируем метод для валидации и сборки
import megasniff
infl = megasniff.SchemaInflatorGenerator()
defl = megasniff.SchemaDeflatorGenerator()
fn_in = infl.schema_to_inflator(SomeSchema1)
fn_out = defl.schema_to_deflator(SomeSchema1)
# 3. Проверяем что всё работает
data = fn_in({'a': 1, 'b': 2, 'c': {'field1': {}, 'field2': '1.1', 'field3': None}})
# SomeSchema1(a=1, b=2.0, c={'field1': {}, 'field2': 1.1, 'field3': None})
fn_out(data)
# {'a': 1, 'b': 2.0, 'c': {'field1': {}, 'field2': 1.1, 'field3': None}}
```
Особенности работы:
- поддерживает циклические зависимости
- проверяет `Union`-типы через ретрай на выбросе исключения
- по умолчанию использует готовый щаблон для кодогенерации и исполняет его по запросу, требуется особое внимание к
сохранности данного шаблона
- проверяет типы списков, может приводить списки к множествам
- не проверяет типы generic-словарей, кортежей (реализация ожидается)
- пользовательские проверки типов должны быть реализованы через наследование и проверки в конструкторе
- опциональный `strict-mode`: выключение приведения базовых типов
- для inflation может генерировать кортежи верхнеуровневых объектов при наличии описания схемы (полезно при
развертывании аргументов)
- `TypedDict` поддерживается только для inflation из-за сложностей выбора варианта при сборке `Union`-полей
- для deflation поддерживается включение режима `explicit_casts`, приводящего типы к тем, которые указаны в
аннотациях (не распространяется на `Union`-типы, т.к. невозможно определить какой из них должен быть выбран)
----
### Как установить:
#### [uv](https://docs.astral.sh/uv/concepts/projects/dependencies/#dependency-sources):
```bash
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):
1. Добавить репозиторий в `pyproject.toml`
```bash
poetry source add --priority=supplemental sniff_index https://git.nikto-b.ru/api/packages/nikto_b/pypi/simple
```
2. Поставить пакет
```bash
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])
>>> A(a=[1, 1.1, "321"])
<<< {"a": [1, 1.1, "321"]} # explicit_casts=False
<<< {"a": [1, 1, 321]} # explicit_casts=True
```
#### Strict-mode on:
```
@dataclass
class A:
a: list[int]
```
```
>>> {"a": [1, 1.1, "321"]}
<<< FieldValidationException, т.к. 1.1 не является int
>>> 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,11 +1,11 @@
[project]
name = "megasniff"
version = "0.1.0"
version = "0.2.6.post1"
description = "Library for in-time codegened type validation"
authors = [
{ name = "nikto_b", email = "niktob560@yandex.ru" }
]
license = { file = "LICENSE" }
license = "LGPL-3.0-or-later"
requires-python = ">=3.13"
dependencies = [
"hatchling>=1.27.0",
@@ -18,3 +18,9 @@ build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/megasniff"]
[dependency-groups]
dev = [
"pytest>=8.4.1",
"pytest-cov>=6.2.1",
]

View File

@@ -1,97 +1,2 @@
# Copyright (C) 2025 Shevchenko A
# SPDX-License-Identifier: LGPL-3.0-or-later
import importlib.resources
from collections.abc import Callable
from dataclasses import dataclass
from types import NoneType, UnionType
from typing import Optional, get_origin, get_args, Union, Annotated
import jinja2
from .utils import *
@dataclass
class RenderData:
argname: str
constrs: list[tuple[str, bool]] # typecall / use lookup table
typename: str
is_union: bool
is_optional: bool
default_option: Optional[str]
class SchemaInflatorGenerator:
templateLoader: jinja2.BaseLoader
templateEnv: jinja2.Environment
template: jinja2.Template
def __init__(self,
loader: Optional[jinja2.BaseLoader] = None,
convertor_template: str = 'inflator.jinja2'):
if loader is None:
template_path = importlib.resources.files('src.megasniff.templates')
loader = jinja2.FileSystemLoader(str(template_path))
self.templateLoader = loader
self.templateEnv = jinja2.Environment(loader=self.templateLoader)
self.template = self.templateEnv.get_template(convertor_template)
def schema_to_generator(self,
schema: type,
*,
_base_lookup_table: Optional[dict[str, Any]] = None) -> Callable[[dict[str, Any]], Any]:
# Я это написал, оно пока работает, и я не собираюсь это упрощать, сорян
type_hints = get_kwargs_type_hints(schema)
render_data = []
lookup_table = _base_lookup_table or {}
if schema.__name__ not in lookup_table.keys():
lookup_table[schema.__name__] = None
for argname, argtype in type_hints.items():
if argname in {'return', 'self'}:
continue
has_default, default_option = get_field_default(schema, argname)
argtypes = argtype,
type_origin = get_origin(argtype)
if any(map(lambda x: type_origin is x, [Union, UnionType, Optional, Annotated])):
argtypes = get_args(argtype)
if NoneType in argtypes or None in argtypes:
argtypes = tuple(filter(lambda x: x is not None and x is not NoneType, argtypes))
has_default = True
out_argtypes: list[tuple[str, bool]] = []
for argt in argtypes:
is_builtin = is_builtin_type(argt)
if not is_builtin and argt is not schema:
if argt.__name__ not in lookup_table.keys():
# если случилась циклическая зависимость, мы не хотим бексконечную рекурсию
lookup_table[argt.__name__] = self.schema_to_generator(argt, _base_lookup_table=lookup_table)
if argt is schema:
out_argtypes.append(('inflate', True))
else:
out_argtypes.append((argt.__name__, is_builtin))
render_data.append(
RenderData(argname, out_argtypes, repr(argtype), len(argtypes) > 1, has_default, default_option))
convertor_functext = self.template.render(conversions=render_data)
convertor_functext = '\n'.join(list(filter(lambda x: len(x.strip()), convertor_functext.split('\n'))))
convertor_functext = convertor_functext.replace(', )', ')')
namespace = {
'_tgt_type': schema,
'_lookup_table': lookup_table
}
exec(convertor_functext, namespace)
# пихаем сгенеренный метод в табличку,
# ожидаем что она обновится во всех вложенных методах,
# разрешая циклические зависимости
lookup_table[schema.__name__] = namespace['inflate']
return namespace['inflate']
from .inflator import SchemaInflatorGenerator
from .deflator import SchemaDeflatorGenerator

View File

@@ -1,13 +1,17 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from types import NoneType
from typing import Optional
from typing import TypedDict
import megasniff.exceptions
from megasniff.deflator import SchemaDeflatorGenerator, JsonObject
from . import SchemaInflatorGenerator
@dataclass
@dataclass(frozen=True)
class ASchema:
a: int
b: float | str
@@ -15,19 +19,81 @@ class ASchema:
c: float = 1.1
class BSchema(TypedDict):
@dataclass
class BSchema:
a: int
b: str
c: float
d: ASchema
@dataclass
class CSchema:
l: set[int | ASchema]
def main():
infl = SchemaInflatorGenerator()
fn = infl.schema_to_generator(ASchema)
d = {'a': '42', 'b': 'a0.3', 'bs': {'a': 1, 'b': 'a', 'c': 1, 'd': {'a': 1, 'b': ''}}}
print(fn(d))
infl = SchemaInflatorGenerator(strict_mode=True)
fn = infl.schema_to_inflator(ASchema)
# print(t)
# print(n)
# exec(t, n)
# fn = n['inflate']
# fn = infl.schema_to_generator(ASchema)
# # d = {'a': '42', 'b': 'a0.3', 'bs': {'a': 1, 'b': 'a', 'c': 1, 'd': {'a': 1, 'b': ''}}}
# 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 = {'l': ['1', {'a': 42, 'b': 1}]}
d = {'a': 2, 'b': '2', 'bs': None}
try:
o = fn(d)
print(o)
for k, v in o.__dict__.items():
print(f'field {k}: {v}')
print(f'type: {type(v)}')
if isinstance(v, list):
for vi in v:
print(f'\ttype: {type(vi)}')
except megasniff.exceptions.FieldValidationException as e:
print(e.exceptions)
print(e)
@dataclass
class DSchema:
a: dict
b: dict[str, int | float | dict]
c: str | float | ASchema
d: ESchema
@dataclass
class ESchema:
a: list[list[list[str]]]
b: str | int
@dataclass
class ZSchema:
z: ZSchema | None
d: ZSchema | int
def main_deflator():
deflator = SchemaDeflatorGenerator(store_sources=True, explicit_casts=True, strict_mode=True)
fn = deflator.schema_to_deflator(DSchema | int)
# ret = fn(ZSchema(ZSchema(ZSchema(None, 42), 42), ZSchema(None, 42)))
ret = fn(DSchema({'a': 34}, {}, ASchema(1, 'a', None), ESchema([[['a'], ['b']]], 'b')))
ret = fn(42)
# assert ret['a'] == 1
# assert ret['b'] == 1.1
# assert ret['c'] == 'a'
# assert ret['d']['a'][0][0][0] == 'a'
# assert ret['d']['b'] == 'b'
print(json.dumps(ret, indent=4))
pass
if __name__ == '__main__':
main()
main_deflator()

347
src/megasniff/deflator.py Normal file
View File

@@ -0,0 +1,347 @@
# Copyright (C) 2025 Shevchenko A
# SPDX-License-Identifier: LGPL-3.0-or-later
from __future__ import annotations
import importlib.resources
import typing
from collections.abc import Callable
from dataclasses import dataclass
from types import NoneType, UnionType
from typing import get_args, Union, Annotated, Sequence, TypeAliasType, \
OrderedDict, TypeAlias
import jinja2
from .utils import *
import uuid
from pathlib import Path
import tempfile
import importlib.util
JsonObject: TypeAlias = Union[None, bool, int, float, str, list['JsonObject'], dict[str, 'JsonObject']]
class Unwrapping:
kind: str
class OtherUnwrapping(Unwrapping):
tp: str
def __init__(self, tp: str = ''):
self.kind = 'other'
self.tp = tp
@dataclass()
class ObjectFieldUnwrapping:
key: str
object_key: str
unwrapping: Unwrapping
class ObjectUnwrapping(Unwrapping):
fields: list[ObjectFieldUnwrapping]
def __init__(self, fields: list[ObjectFieldUnwrapping]):
self.kind = 'object'
self.fields = fields
class ListUnwrapping(Unwrapping):
item_unwrap: Unwrapping
def __init__(self, item_unwrap: Unwrapping):
self.kind = 'list'
self.item_unwrap = item_unwrap
class DictUnwrapping(Unwrapping):
key_unwrap: Unwrapping
value_unwrap: Unwrapping
def __init__(self, key_unwrap: Unwrapping, value_unwrap: Unwrapping):
self.kind = 'dict'
self.key_unwrap = key_unwrap
self.value_unwrap = value_unwrap
class FuncUnwrapping(Unwrapping):
fn: str
def __init__(self, fn: str):
self.kind = 'fn'
self.fn = fn
@dataclass
class UnionKindUnwrapping:
kind: str
unwrapping: Unwrapping
class UnionUnwrapping(Unwrapping):
union_kinds: list[UnionKindUnwrapping]
def __init__(self, union_kinds: list[UnionKindUnwrapping]):
self.kind = 'union'
self.union_kinds = union_kinds
def _flatten_type(t: type | TypeAliasType) -> tuple[type, Optional[str]]:
if isinstance(t, TypeAliasType):
return _flatten_type(t.__value__)
origin = get_origin(t)
if origin is Annotated:
args = get_args(t)
return _flatten_type(args[0])[0], args[1]
return t, None
def _schema_to_deflator_func(t: type | TypeAliasType) -> str:
t, _ = _flatten_type(t)
return ('deflate_' + typename(t)
.replace('.', '_')
.replace('[', '_of_')
.replace(']', '_of_')
.replace(',', '_and_')
.replace(' ', '_'))
def _fallback_unwrapper(obj: Any) -> JsonObject:
if isinstance(obj, (int, float, str, bool)):
return obj
elif isinstance(obj, list):
return list(map(_fallback_unwrapper, obj))
elif isinstance(obj, dict):
return dict(map(lambda x: (_fallback_unwrapper(x[0]), _fallback_unwrapper(x[1])), obj.items()))
elif hasattr(obj, '__dict__'):
ret = {}
for k, v in obj.__dict__:
if isinstance(k, str) and k.startswith('_'):
continue
k = _fallback_unwrapper(k)
v = _fallback_unwrapper(v)
ret[k] = v
return ret
return None
class SchemaDeflatorGenerator:
templateLoader: jinja2.BaseLoader
templateEnv: jinja2.Environment
object_template: jinja2.Template
_store_sources: bool
_strict_mode: bool
_explicit_casts: bool
_out_directory: str | None
def __init__(self,
loader: Optional[jinja2.BaseLoader] = None,
strict_mode: bool = False,
explicit_casts: bool = False,
store_sources: bool = False,
*,
object_template_filename: str = 'deflator.jinja2',
out_directory: str | None = None,
):
self._strict_mode = strict_mode
self._store_sources = store_sources
self._explicit_casts = explicit_casts
self._out_directory = out_directory
if loader is None:
template_path = importlib.resources.files('megasniff.templates')
loader = jinja2.FileSystemLoader(str(template_path))
self.templateLoader = loader
self.templateEnv = jinja2.Environment(loader=self.templateLoader)
self.object_template = self.templateEnv.get_template(object_template_filename)
def schema_to_deflator(self,
schema: type,
strict_mode_override: Optional[bool] = None,
explicit_casts_override: Optional[bool] = None,
ignore_directory: bool = False,
out_directory_override: Optional[str] = None,
) -> Callable[[Any], dict[str, Any]]:
txt, namespace = self._schema_to_deflator(schema,
strict_mode_override=strict_mode_override,
explicit_casts_override=explicit_casts_override,
)
out_dir = self._out_directory
if out_directory_override:
out_dir = out_directory_override
if ignore_directory:
out_dir = None
imports = ('from typing import Any\n'
'from megasniff.exceptions import MissingFieldException, FieldValidationException\n')
txt = imports + '\n' + txt
if out_dir is not None:
filename = f"{uuid.uuid4()}.py"
filepath = Path(out_dir) / filename
filepath.parent.mkdir(parents=True, exist_ok=True)
with open(filepath, 'w', encoding='utf-8') as f:
f.write(txt)
spec = importlib.util.spec_from_file_location("generated_module", filepath)
module = importlib.util.module_from_spec(spec)
module.__dict__.update(namespace)
spec.loader.exec_module(module)
fn_name = _schema_to_deflator_func(schema)
fn = getattr(module, fn_name)
else:
exec(txt, namespace)
fn = namespace[_schema_to_deflator_func(schema)]
if self._store_sources:
setattr(fn, '__megasniff_sources__', txt)
return fn
def schema_to_unwrapper(self, schema: type | TypeAliasType, *, _visited_types: Optional[list[type]] = None):
if _visited_types is None:
_visited_types = []
else:
_visited_types = _visited_types.copy()
schema, field_rename = _flatten_type(schema)
if schema in _visited_types:
return FuncUnwrapping(_schema_to_deflator_func(schema)), field_rename, set(), {schema}
_visited_types.append(schema)
ongoing_types = set()
recurcive_types = set()
origin = get_origin(schema)
ret_unw = None
if origin is not None:
if origin is list:
args = get_args(schema)
item_unw, arg_rename, ongoings, item_rec = self.schema_to_unwrapper(args[0],
_visited_types=_visited_types)
ret_unw = ListUnwrapping(item_unw)
recurcive_types |= item_rec
ongoing_types |= ongoings
elif origin is dict:
args = get_args(schema)
if len(args) != 2:
ret_unw = OtherUnwrapping()
else:
k, v = args
k_unw, _, k_ongoings, k_rec = self.schema_to_unwrapper(k, _visited_types=_visited_types)
v_unw, _, v_ongoings, v_rec = self.schema_to_unwrapper(k, _visited_types=_visited_types)
ongoing_types |= k_ongoings | v_ongoings
recurcive_types |= k_rec | v_rec
ret_unw = DictUnwrapping(k_unw, v_unw)
elif origin is UnionType or origin is Union:
args = get_args(schema)
union_unwraps = []
for targ in args:
arg_unw, arg_rename, ongoings, arg_rec = self.schema_to_unwrapper(targ,
_visited_types=_visited_types)
union_unwraps.append(UnionKindUnwrapping(typename(targ), arg_unw))
ongoing_types |= ongoings
recurcive_types |= arg_rec
ret_unw = UnionUnwrapping(union_unwraps)
else:
raise NotImplementedError
else:
if schema is int:
ret_unw = OtherUnwrapping('int')
elif schema is float:
ret_unw = OtherUnwrapping('float')
elif schema is bool:
ret_unw = OtherUnwrapping('bool')
elif schema is str:
ret_unw = OtherUnwrapping('str')
elif schema is None or schema is NoneType:
ret_unw = OtherUnwrapping()
elif schema is dict:
ret_unw = OtherUnwrapping()
elif schema is list:
ret_unw = OtherUnwrapping()
elif is_class_definition(schema):
hints = typing.get_type_hints(schema)
fields = []
for k, f in hints.items():
f_unw, f_rename, ongoings, f_rec = self.schema_to_unwrapper(f, _visited_types=_visited_types)
fields.append(ObjectFieldUnwrapping(f_rename or k, k, f_unw))
ongoing_types |= ongoings
recurcive_types |= f_rec
ret_unw = ObjectUnwrapping(fields)
else:
raise NotImplementedError(f'type not implemented yet: {schema}')
return ret_unw, field_rename, set(_visited_types) | ongoing_types, recurcive_types
def _schema_to_deflator(self,
schema: type | Sequence[TupleSchemaItem | tuple[str, type]] | OrderedDict[str, type],
strict_mode_override: Optional[bool] = None,
explicit_casts_override: Optional[bool] = None,
into_type_override: Optional[type | TypeAliasType] = None,
*,
_funcname='deflate',
_namespace=None,
) -> tuple[str, dict]:
if strict_mode_override is not None:
strict_mode = strict_mode_override
else:
strict_mode = self._strict_mode
if explicit_casts_override is not None:
explicit_casts = explicit_casts_override
else:
explicit_casts = self._explicit_casts
template = self.object_template
types_for_namespace = set()
recursive_types = {schema}
namespace = {
'JsonObject': JsonObject,
'fallback_unwrapper': _fallback_unwrapper,
}
convertor_functext = ''
added_types = set()
while len(recursive_types ^ (recursive_types & added_types)) > 0:
rec_t = list(recursive_types ^ (recursive_types & added_types))[0]
rec_unw, _, rec_t_namespace, rec_rec_t = self.schema_to_unwrapper(rec_t)
recursive_types |= rec_rec_t
types_for_namespace |= rec_t_namespace
rec_functext = template.render(
funcname=_schema_to_deflator_func(rec_t),
from_type=typename(rec_t),
into_type=None,
root_unwrap=rec_unw,
hashname=hashname,
strict_check=strict_mode,
explicit_cast=explicit_casts,
)
convertor_functext += '\n\n\n' + rec_functext
added_types.add(rec_t)
for t in types_for_namespace:
namespace[typename(t)] = t
convertor_functext = '\n'.join(list(filter(lambda x: len(x.strip()), convertor_functext.split('\n'))))
return convertor_functext, namespace

View File

@@ -0,0 +1,22 @@
from typing import Any, Optional
class MissingFieldException(Exception):
def __init__(self, required_field: str, required_types: str):
message = f"No required field provided: {required_field} with type {required_types}"
super().__init__(message)
self.required_field = required_field
self.required_types = required_types
class FieldValidationException(Exception):
def __init__(self,
required_field: str,
required_types: str,
provided: Any,
exceptions: Optional[list[Exception]] = None):
message = f"Required field {required_field} with type {required_types}, provided: {provided}"
super().__init__(message)
self.required_field = required_field
self.required_types = required_types
self.exceptions = exceptions or []

308
src/megasniff/inflator.py Normal file
View File

@@ -0,0 +1,308 @@
# Copyright (C) 2025 Shevchenko A
# SPDX-License-Identifier: LGPL-3.0-or-later
from __future__ import annotations
import collections.abc
import importlib.resources
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, \
OrderedDict
import jinja2
from . import utils
from .utils import *
@dataclass
class TypeRenderData:
typeref: list[TypeRenderData] | TypeRenderData | str
allow_none: bool
is_list: bool
is_union: bool
is_strict: bool
@dataclass
class IterableTypeRenderData(TypeRenderData):
iterable_type: str
is_list = True
is_union = False
def _escape_python_name(name: str) -> str:
name = name.replace('-', '__dash__').replace('+', '__plus__').replace('/', '__shash__')
if name[0].isnumeric():
name = '__num__' + name
return name
@dataclass
class FieldRenderData:
argname: str
argname_escaped: str
constrs: TypeRenderData
typename: str
is_optional: bool
allow_none: bool
default_option: Optional[str]
def __init__(self,
argname: str,
constrs: TypeRenderData,
typename: str,
is_optional: bool,
allow_none: bool,
default_option: Optional[str]):
self.argname = argname
self.constrs = constrs
self.typename = typename
self.is_optional = is_optional
self.allow_none = allow_none
self.default_option = default_option
self.argname_escaped = _escape_python_name(argname)
class SchemaInflatorGenerator:
templateLoader: jinja2.BaseLoader
templateEnv: jinja2.Environment
object_template: jinja2.Template
tuple_template: jinja2.Template
_store_sources: bool
_strict_mode: bool
_out_directory: str | None
def __init__(self,
loader: Optional[jinja2.BaseLoader] = None,
strict_mode: bool = False,
store_sources: bool = False,
*,
object_template_filename: str = 'inflator.jinja2',
tuple_template_filename: str = 'inflator_tuple.jinja2',
out_directory: str | None = None,
):
self._strict_mode = strict_mode
self._store_sources = store_sources
self._out_directory = out_directory
if loader is None:
template_path = importlib.resources.files('megasniff.templates')
loader = jinja2.FileSystemLoader(str(template_path))
self.templateLoader = loader
self.templateEnv = jinja2.Environment(loader=self.templateLoader)
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 | Sequence[TupleSchemaItem | tuple[str, type]] | OrderedDict[str, type],
strict_mode_override: Optional[bool] = None,
from_type_override: Optional[type | TypeAliasType] = None,
ignore_directory: bool = False,
out_directory_override: Optional[str] = None,
) -> Callable[[dict[str, Any]], Any]:
if from_type_override is not None and '__getitem__' not in dir(from_type_override):
raise RuntimeError('from_type_override must provide __getitem__')
txt, namespace = self._schema_to_inflator(schema,
_funcname='inflate',
strict_mode_override=strict_mode_override,
from_type_override=from_type_override,
)
out_dir = self._out_directory
if out_directory_override:
out_dir = out_directory_override
if ignore_directory:
out_dir = None
imports = ('from typing import Any\n'
'from megasniff.exceptions import MissingFieldException, FieldValidationException\n')
txt = imports + '\n' + txt
if out_dir is not None:
filename = f"{uuid.uuid4()}.py"
filepath = Path(out_dir) / filename
filepath.parent.mkdir(parents=True, exist_ok=True)
with open(filepath, 'w', encoding='utf-8') as f:
f.write(txt)
spec = importlib.util.spec_from_file_location("generated_module", filepath)
module = importlib.util.module_from_spec(spec)
module.__dict__.update(namespace)
spec.loader.exec_module(module)
fn_name = _schema_to_deflator_func(schema)
fn = getattr(module, fn_name)
else:
exec(txt, namespace)
fn = namespace['inflate']
if self._store_sources:
setattr(fn, '__megasniff_sources__', txt)
return fn
def _unwrap_typeref(self, t: type, strict_mode: bool) -> TypeRenderData:
type_origin = get_origin(t)
allow_none = False
argtypes = t,
if any(map(lambda x: type_origin is x, [Union, UnionType, Optional, Annotated, list, List, set, Set])):
argtypes = get_args(t)
if NoneType in argtypes or None in argtypes:
argtypes = tuple(filter(lambda x: x is not None and x is not NoneType, argtypes))
allow_none = True
is_union = len(argtypes) > 1
if is_union:
typerefs = list(map(lambda x: self._unwrap_typeref(x, strict_mode), argtypes))
return TypeRenderData(typerefs, allow_none, False, True, False)
elif type_origin in [list, set]:
rd = self._unwrap_typeref(argtypes[0], strict_mode)
return IterableTypeRenderData(rd, allow_none, True, False, False, type_origin.__name__)
else:
t = argtypes[0]
is_list = (type_origin or t) in [list, set]
if is_list:
t = type_origin or t
is_builtin = is_builtin_type(t)
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,
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,
) -> 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
# Я это написал, оно пока работает, и я не собираюсь это упрощать, сорян
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 = []
if _namespace is None:
namespace = {}
else:
namespace = _namespace
if namespace.get(f'{_funcname}_tgt_type') is not None:
return '', namespace
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
for argname, argtype in type_hints.items():
if argname in {'return', 'self'}:
continue
has_default, default_option = get_field_default(schema, argname)
typeref = self._unwrap_typeref(argtype, strict_mode)
argtypes = argtype,
allow_none = False
while get_origin(argtype) is not None:
type_origin = get_origin(argtype)
if any(map(lambda x: type_origin is x, [Union, UnionType, Optional, Annotated, list, List, set, Set])):
argtypes = get_args(argtype)
if len(argtypes) == 1:
argtype = argtypes[0]
else:
break
if NoneType in argtypes or None in argtypes:
argtypes = tuple(filter(lambda x: x is not None and x is not NoneType, argtypes))
allow_none = True
render_data.append(
FieldRenderData(
argname,
typeref,
utils.typename(argtype),
has_default,
allow_none,
default_option if not isinstance(default_option, str) else f"'{default_option}'",
)
)
for argt in argtypes:
is_builtin = is_builtin_type(argt)
if not is_builtin and argt is not schema:
# если случилась циклическая зависимость, мы не хотим бексконечную рекурсию
if argt.__name__ not in namespace.keys():
t, n = self._schema_to_inflator(argt,
_funcname=f'inflate_{argt.__name__}',
_namespace=namespace,
strict_mode_override=strict_mode_override)
namespace |= n
txt_segments.append(t)
elif argt is schema:
pass
else:
namespace[argt.__name__] = argt
convertor_functext = template.render(
funcname=_funcname,
conversions=render_data,
tgt_type=utils.typename(schema),
from_type='_from_type' if from_type_override is not None else None
)
convertor_functext = '\n'.join(txt_segments) + '\n\n' + convertor_functext
convertor_functext = '\n'.join(list(filter(lambda x: len(x.strip()), convertor_functext.split('\n'))))
return convertor_functext, namespace

View File

@@ -0,0 +1,110 @@
{% macro render_unwrap_object(unwrapping, from_container, into_container) -%}
{%- set out -%}
{{ into_container }} = {}
{% for kv in unwrapping.fields %}
{{ render_unwrap(kv.unwrapping, from_container + '.' + kv.object_key, into_container + "['" + kv.key + "']") }}
{% endfor %}
{%- endset %}
{{out}}
{%- endmacro %}
{% macro render_unwrap_dict(unwrapping, from_container, into_container) -%}
{%- set out -%}
{{ into_container }} = {}
{% if strict_check %}
if not isinstance({{from_container}}, dict):
raise FieldValidationException('{{from_container.replace("'", "\\'")}}', 'dict', str(type({{from_container}})))
{% endif %}
{% if explicit_cast %}
{% set from_container = 'dict(' + from_container + ')' %}
{% endif %}
for k_{{hashname(unwrapping)}}, v_{{hashname(unwrapping)}} in {{from_container}}.items():
{{ render_unwrap(unwrapping.key_unwrap, 'k_' + hashname(unwrapping), 'k_' + hashname(unwrapping)) | indent(4) }}
{{ render_unwrap(unwrapping.value_unwrap, 'v_' + hashname(unwrapping), into_container + '[k_' + hashname(unwrapping) + ']') | indent(4) }}
{%- endset %}
{{out}}
{%- endmacro %}
{% macro render_unwrap_list(unwrapping, from_container, into_container) -%}
{%- set out -%}
{{into_container}} = []
{% if strict_check %}
if not isinstance({{from_container}}, list):
raise FieldValidationException('{{from_container.replace("'", "\\'")}}', 'list', str(type({{from_container}})))
{% endif %}
{% if explicit_cast %}
{% set from_container = 'list(' + from_container + ')' %}
{% endif %}
for {{hashname(unwrapping)}} in {{from_container}}:
{{ render_unwrap(unwrapping.item_unwrap, hashname(unwrapping), hashname(unwrapping)+'_tmp_container') | indent(4) }}
{{into_container}}.append({{hashname(unwrapping)}}_tmp_container)
{%- endset %}
{{out}}
{%- endmacro %}
{% macro render_unwrap_other(unwrapping, from_container, into_container) -%}
{%- set out -%}
{% if unwrapping.tp != '' and strict_check %}
if not isinstance({{from_container}}, {{unwrapping.tp}}):
raise FieldValidationException('{{from_container.replace("'", "\\'")}}', '{{unwrapping.tp}}', str(type({{from_container}})))
{% endif %}
{% if unwrapping.tp != '' and explicit_cast %}
{{into_container}} = {{unwrapping.tp}}({{from_container}})
{% else %}
{{into_container}} = {{from_container}}
{% endif %}
{%- endset %}
{{out}}
{%- endmacro %}
{% macro render_unwrap_union(unwrapping, from_container, into_container) -%}
{%- set out -%}
{% for union_kind in unwrapping.union_kinds %}
{% if loop.index > 1 %}el{% endif %}if isinstance({{from_container}}, {{union_kind.kind}}):
{{render_unwrap(union_kind.unwrapping, from_container, into_container) | indent(4)}}
{% endfor %}
{% if strict_check %}
else:
raise FieldValidationException('{{from_container.replace("'", "\\'")}}', 'dict', str(type({{from_container}})))
{% elif explicit_cast %}
else:
{{render_unwrap(unwrapping.union_kinds[-1], from_container, into_container) | indent(4)}}
{% else %}
else:
{{into_container}} = fallback_unwrap({{from_container}})
{% endif %}
{%- endset %}
{{out}}
{%- endmacro %}
{% macro render_unwrap(unwrapping, from_container, into_container) -%}
{%- set out -%}
{% if unwrapping.kind == 'dict' %}
{{ render_unwrap_dict(unwrapping, from_container, into_container) }}
{% elif unwrapping.kind == 'list' %}
{{ render_unwrap_list(unwrapping, from_container, into_container) }}
{% elif unwrapping.kind == 'object' %}
{{ render_unwrap_object(unwrapping, from_container, into_container) }}
{% elif unwrapping.kind == 'union' %}
{{ render_unwrap_union(unwrapping, from_container, into_container) }}
{% elif unwrapping.kind == 'fn' %}
{{into_container}} = {{ unwrapping.fn }}({{from_container}})
{% else %}
{{ render_unwrap_other(unwrapping, from_container, into_container) }}
{% endif %}
{%- endset %}
{{out}}
{%- endmacro %}
def {{funcname}}(from_data{% if from_type is not none%}: {{from_type}}{%endif%}) -> {% if into_type is none %}JsonObject{%else%}{{into_type}}{%endif%}:
"""
{{from_type}} -> {{into_type}}
"""
{{ render_unwrap(root_unwrap, 'from_data', 'ret') | indent(4) }}
return ret

View File

@@ -1,56 +1,32 @@
{% set ns = namespace(retry_indent=0) %}
from typing import Any
def inflate(from_data: dict[str, Any]):
from_data_keys = set(from_data.keys())
{% 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 %} -> {{tgt_type}} {% endif %}:
"""
{{tgt_type}}
"""
from_data_keys = from_data.keys()
{% for conv in conversions %}
{% if not conv.is_optional or conv.default_option is not none%}
if '{{conv.argname}}' not in from_data_keys:
{% if not conv.is_optional %}
raise ValueError(f"No required field provided: {{conv.argname}} with type {{conv.typename | replace('"', "'")}}")
{% endif %}
{% if conv.default_option is not none %}
from_data['{{conv.argname}}'] = {{conv.default_option}}
{% endif%}
{% endif %}
{%endfor%}
{% for conv in conversions %}
{% if conv.is_union %}
{% set ns.retry_indent = 0 %}
{% for union_type, is_builtin in conv.constrs %}
{{ ' ' * ns.retry_indent }}try:
{% if is_builtin %}
{{ ' ' * ns.retry_indent }} {{conv.argname}} = {{union_type}}(from_data['{{conv.argname}}'])
{% else %}
{{ ' ' * ns.retry_indent }} {{conv.argname}} = _lookup_table['{{union_type}}'](from_data['{{conv.argname}}'])
{% endif %}
{{ ' ' * ns.retry_indent }}except Exception as e:
{% set ns.retry_indent = ns.retry_indent + 1 %}
{% endfor %}
{{ ' ' * ns.retry_indent }} raise e from e
{% else %}
{% if conv.constrs[0][1] %}
{% if conv.is_optional %}
if '{{conv.argname}}' not in from_data_keys:
{{conv.argname}} = None
{{conv.argname_escaped}} = {{conv.default_option}}
{% else %}
raise MissingFieldException('{{conv.argname}}', "{{conv.typename | replace('"', "'")}}")
{% endif %}
else:
{{conv.argname}} = {{conv.constrs[0][0]}}(from_data['{{conv.argname}}'])
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}} = {{conv.constrs[0][0]}}(from_data['{{conv.argname}}'])
{{conv.argname_escaped}} = None
{% endif %}
{% else %}
{% if conv.is_optional %}
if '{{conv.argname}}' not in from_data_keys:
{{conv.argname}} = None
else:
{{conv.argname}} = _lookup_table['{{conv.constrs[0][0]}}'](from_data['{{conv.argname}}'])
{% else %}
{{conv.argname}} = _lookup_table['{{conv.constrs[0][0]}}'](from_data['{{conv.argname}}'])
{% endif %}
{% endif %}
{% endif %}
{{ unwrap_type_data.render_segment(conv.argname_escaped, conv.constrs, "conv_data", false) | indent(4*3) }}
{% endfor %}
return _tgt_type({% for conv in conversions %}{{conv.argname}}={{conv.argname}}, {% endfor %})
return {{funcname}}_tgt_type({% for conv in conversions %}{{conv.argname_escaped}}={{conv.argname_escaped}}, {% endfor %})

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_escaped}}:{{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_escaped}} = {{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_escaped}} = None
{% endif %}
else:
{{ unwrap_type_data.render_segment(conv.argname_escaped, conv.constrs, "conv_data", false) | indent(4*3) }}
{% endfor %}
return ({% for conv in conversions %}{{conv.argname_escaped}}, {% endfor %})

View File

@@ -0,0 +1,55 @@
{% macro render_iterable(argname, typedef, conv_data) -%}
{%- set out -%}
{{argname}} = []
if not isinstance({{conv_data}}, list):
raise FieldValidationException('{{argname}}', "list", conv_data, [])
for item in {{conv_data}}:
{{ render_segment("_" + argname, typedef, "item", false ) | indent(4) }}
{{argname}}.append(_{{argname}})
{%- endset %}
{{out}}
{%- endmacro %}
{% macro render_union(argname, conv, conv_data) -%}
{%- set out -%}
# unwrapping union {{conv}}
{% set ns = namespace(retry_indent=0) %}
{% set ns.retry_indent = 0 %}
all_conv_exceptions = []
{% for union_type in conv.typeref %}
{{ ' ' * ns.retry_indent }}try:
{{ render_segment(argname, union_type, conv_data, false) | indent((ns.retry_indent + 1) * 4) }}
{{ ' ' * ns.retry_indent }}except Exception as e:
{{ ' ' * ns.retry_indent }} all_conv_exceptions.append(e)
{% set ns.retry_indent = ns.retry_indent + 1 %}
{% endfor %}
{{ ' ' * ns.retry_indent }}raise FieldValidationException('{{conv.argname}}', "{{conv.typename | replace('"', "'")}}", conv_data, all_conv_exceptions)
{%- endset %}
{{out}}
{%- endmacro %}
{% macro render_segment(argname, typeref, conv_data, strict) -%}
{%- set out -%}
{% if typeref is string %}
{% if strict %}
if not isinstance({{conv_data}}, {{typeref}}):
raise FieldValidationException('{{argname}}', "{{typeref | replace('"', "'")}}", {{conv_data}}, [])
{% endif %}
{{argname}} = {{typeref}}({{conv_data}})
{% elif typeref.is_union %}
{{render_union(argname, typeref, conv_data)}}
{% elif typeref.is_list %}
{{render_iterable(argname, typeref.typeref, conv_data)}}
{{argname}} = {{typeref.iterable_type}}({{argname}})
{% else %}
{{render_segment(argname, typeref.typeref, conv_data, typeref.is_strict)}}
{% endif %}
{%- endset %}
{{out}}
{%- endmacro %}

View File

@@ -1,6 +1,15 @@
import collections.abc
import dataclasses
import inspect
from typing import get_type_hints, Any
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:]
@@ -50,3 +65,29 @@ def get_field_default(cls: type[Any], field: str) -> tuple[bool, Any]:
def is_builtin_type(tp: type) -> bool:
return getattr(tp, '__module__', None) == 'builtins'
def typename(tp: type) -> str:
ret = ''
if get_origin(tp) is None and hasattr(tp, '__name__'):
ret = tp.__name__
else:
ret = str(tp)
ret = (ret
.replace('.', '_')
.replace('[', '_of_')
.replace(']', '_of_')
.replace(',', '_and_')
.replace(' ', '_')
.replace('\'', '')
.replace('<', '')
.replace('>', ''))
return ret
def is_class_definition(obj):
return (isinstance(obj, type) or inspect.isclass(obj))
def hashname(obj) -> str:
return '_' + str(hash(obj)).replace('-', '_')

62
tests/test_basic.py Normal file
View File

@@ -0,0 +1,62 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
from src.megasniff import SchemaInflatorGenerator
def test_basic_constructor():
class A:
def __init__(self, a: int):
self.a = a
infl = SchemaInflatorGenerator()
fn = infl.schema_to_inflator(A)
a = fn({'a': 42})
assert a.a == 42
def test_unions():
@dataclass
class A:
a: int | str
infl = SchemaInflatorGenerator()
fn = infl.schema_to_inflator(A)
a = fn({'a': 42})
assert a.a == 42
a = fn({'a': '42'})
assert a.a == 42
a = fn({'a': '42a'})
assert a.a == '42a'
@dataclass
class CircA:
b: CircB
@dataclass
class CircB:
a: CircA | None
def test_circular():
infl = SchemaInflatorGenerator()
fn = infl.schema_to_inflator(CircA)
a = fn({'b': {'a': None}})
assert isinstance(a.b, CircB)
def test_optional():
@dataclass
class C:
a: Optional[int] = None
infl = SchemaInflatorGenerator()
fn = infl.schema_to_inflator(C)
c = fn({})
assert c.a is None

View File

@@ -0,0 +1,91 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
from megasniff.deflator import SchemaDeflatorGenerator
from src.megasniff import SchemaInflatorGenerator
def test_basic_deflator():
class A:
a: int
def __init__(self, a: int):
self.a = a
class B:
def __init__(self, b: int):
self.b = b
defl = SchemaDeflatorGenerator()
fn = defl.schema_to_deflator(A)
a = fn(A(42))
assert a['a'] == 42
fnb = defl.schema_to_deflator(B)
b = fnb(B(11))
assert len(b) == 0
def test_unions():
@dataclass
class A:
a: int | str
defl = SchemaDeflatorGenerator()
fn = defl.schema_to_deflator(A)
a = fn(A(42))
assert a['a'] == 42
a = fn(A('42'))
assert a['a'] == '42'
a = fn(A('42a'))
assert a['a'] == '42a'
def test_dict_body():
@dataclass
class A:
a: dict[str, float]
defl = SchemaDeflatorGenerator()
fn = defl.schema_to_deflator(A)
a = fn(A({'1': 1.1, '2': 2.2}))
print(a)
assert a['a']['1'] == 1.1
assert a['a']['2'] == 2.2
@dataclass
class CircA:
b: CircB
@dataclass
class CircB:
a: CircA | None
def test_circular():
defl = SchemaDeflatorGenerator()
fn = defl.schema_to_deflator(CircA)
a = fn(CircA(CircB(CircA(CircB(None)))))
assert isinstance(a['b'], dict)
assert isinstance(a['b']['a'], dict)
assert a['b']['a']['b']['a'] is None
def test_optional():
@dataclass
class C:
a: Optional[int] = None
defl = SchemaDeflatorGenerator()
fn = defl.schema_to_deflator(C)
c = fn(C())
assert c['a'] is None
c = fn(C(123))
assert c['a'] == 123

39
tests/test_exceptions.py Normal file
View File

@@ -0,0 +1,39 @@
from dataclasses import dataclass
import pytest
from megasniff import SchemaInflatorGenerator
from megasniff.exceptions import MissingFieldException, FieldValidationException
def test_missing_field():
@dataclass
class A:
a: int
infl = SchemaInflatorGenerator()
fn = infl.schema_to_inflator(A)
with pytest.raises(MissingFieldException):
fn({})
def test_null():
@dataclass
class A:
a: int
infl = SchemaInflatorGenerator()
fn = infl.schema_to_inflator(A)
with pytest.raises(FieldValidationException):
fn({'a': None})
def test_invalid_field():
@dataclass
class A:
a: float | int | None
infl = SchemaInflatorGenerator()
fn = infl.schema_to_inflator(A)
with pytest.raises(FieldValidationException):
fn({'a': {}})

View File

@@ -0,0 +1,94 @@
from dataclasses import dataclass
import pytest
from megasniff import SchemaDeflatorGenerator
from megasniff.exceptions import FieldValidationException
def test_global_explicit_casts_basic():
class A:
a: int
def __init__(self, a):
self.a = a
defl = SchemaDeflatorGenerator(explicit_casts=True)
fn = defl.schema_to_deflator(A)
a = fn(A(42))
assert a['a'] == 42
a = fn(A(42.0))
assert a['a'] == 42
a = fn(A('42'))
assert a['a'] == 42
with pytest.raises(TypeError):
fn(A(['42']))
def test_global_explicit_casts_basic_override():
class A:
a: int
def __init__(self, a):
self.a = a
defl = SchemaDeflatorGenerator(explicit_casts=False)
fn = defl.schema_to_deflator(A, explicit_casts_override=True)
a = fn(A(42))
assert a['a'] == 42
a = fn(A(42.0))
assert a['a'] == 42
a = fn(A('42'))
assert a['a'] == 42
with pytest.raises(TypeError):
fn(A(['42']))
def test_global_explicit_casts_list():
@dataclass
class A:
a: list[int]
defl = SchemaDeflatorGenerator(explicit_casts=True)
fn = defl.schema_to_deflator(A)
a = fn(A([42]))
assert a['a'] == [42]
a = fn(A([42.0, 42]))
assert len(a['a']) == 2
assert a['a'][0] == 42
assert a['a'][1] == 42
def test_global_explicit_casts_circular():
@dataclass
class A:
a: list[int]
@dataclass
class B:
b: list[A | int]
defl = SchemaDeflatorGenerator(explicit_casts=True)
fn = defl.schema_to_deflator(B)
b = fn(B([A([]), 42]))
assert len(b['b']) == 2
assert isinstance(b['b'][0], dict)
assert len(b['b'][0]['a']) == 0
assert isinstance(b['b'][1], int)
b = fn(B([42.0]))
assert b['b'][0] == 42
b = fn(B([A([1.1])]))
assert b['b'][0]['a'][0] == 1

View File

@@ -0,0 +1,53 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
import pytest
from megasniff.deflator import SchemaDeflatorGenerator
from src.megasniff import SchemaInflatorGenerator
def test_str_deflator():
defl = SchemaDeflatorGenerator()
fn = defl.schema_to_deflator(str, explicit_casts_override=True)
a = fn('asdf')
assert a == 'asdf'
a = fn(1234)
assert a == '1234'
fn1 = defl.schema_to_deflator(str, strict_mode_override=True)
with pytest.raises(Exception):
fn1(1234)
def test_int_deflator():
defl = SchemaDeflatorGenerator()
fn = defl.schema_to_deflator(int, explicit_casts_override=True)
a = fn(1234)
assert a == 1234
a = fn('1234')
assert a == 1234
fn1 = defl.schema_to_deflator(int, strict_mode_override=True)
with pytest.raises(Exception):
fn1('1234')
def test_float_deflator():
defl = SchemaDeflatorGenerator()
fn = defl.schema_to_deflator(float, explicit_casts_override=True)
a = fn(1234.1)
assert a == 1234.1
a = fn('1234')
assert a == 1234.0
fn1 = defl.schema_to_deflator(float, strict_mode_override=True)
with pytest.raises(Exception):
fn1(1234)

87
tests/test_iterables.py Normal file
View File

@@ -0,0 +1,87 @@
from dataclasses import dataclass
from megasniff import SchemaInflatorGenerator
def test_list_basic():
@dataclass
class A:
l: list[int]
infl = SchemaInflatorGenerator()
fn = infl.schema_to_inflator(A)
a = fn({'l': []})
assert isinstance(a.l, list)
assert len(a.l) == 0
a = fn({'l': [1, 2.1, '0']})
print(a.l)
assert isinstance(a.l, list)
assert len(a.l) == 3
assert all(map(lambda x: isinstance(x, int), a.l))
@dataclass
class B:
l: list[str]
fn = infl.schema_to_inflator(B)
a = fn({'l': [1, 2.1, '0']})
print(a.l)
assert isinstance(a.l, list)
assert len(a.l) == 3
assert all(map(lambda x: isinstance(x, str), a.l))
assert a.l == ['1', '2.1', '0']
def test_list_union():
@dataclass
class A:
l: list[int | str]
infl = SchemaInflatorGenerator()
fn = infl.schema_to_inflator(A)
a = fn({'l': []})
assert isinstance(a.l, list)
assert len(a.l) == 0
a = fn({'l': [1, 2.1, '0']})
print(a.l)
assert isinstance(a.l, list)
assert len(a.l) == 3
assert all(map(lambda x: isinstance(x, int), a.l))
def test_set_basic():
@dataclass
class A:
l: set[int]
infl = SchemaInflatorGenerator()
fn = infl.schema_to_inflator(A)
a = fn({'l': []})
assert isinstance(a.l, set)
assert len(a.l) == 0
a = fn({'l': [1, 2.1, '0']})
print(a.l)
assert isinstance(a.l, set)
assert len(a.l) == 3
assert all(map(lambda x: isinstance(x, int), a.l))
@dataclass
class B:
l: set[str]
fn = infl.schema_to_inflator(B)
a = fn({'l': [1, 2.1, '0', 0]})
print(a.l)
assert isinstance(a.l, set)
assert len(a.l) == 3
assert all(map(lambda x: isinstance(x, str), a.l))
assert a.l == {'1', '2.1', '0'}

43
tests/test_signature.py Normal file
View File

@@ -0,0 +1,43 @@
from dataclasses import dataclass
from typing import get_type_hints, Any, Annotated
from megasniff import SchemaInflatorGenerator
def test_return_signature():
@dataclass
class A:
a: list[int]
infl = SchemaInflatorGenerator(strict_mode=True)
fn = infl.schema_to_inflator(A)
hints = get_type_hints(fn)
assert hints['return'] == A
assert len(hints) == 2
def test_argument_signature():
@dataclass
class A:
a: list[int]
infl = SchemaInflatorGenerator(strict_mode=True)
type custom_from_type = dict[str, Any]
fn1 = infl.schema_to_inflator(A, from_type_override=custom_from_type)
fn2 = infl.schema_to_inflator(A)
hints = get_type_hints(fn1)
assert hints['return'] == A
assert len(hints) == 2
assert hints['from_data'] == custom_from_type
assert hints['from_data'] != dict[str, Any]
hints = get_type_hints(fn2)
assert hints['return'] == A
assert len(hints) == 2
assert hints['from_data'] != custom_from_type
assert hints['from_data'] == dict[str, Any]

75
tests/test_strict_mode.py Normal file
View 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]}]})

View File

@@ -0,0 +1,88 @@
from dataclasses import dataclass
import pytest
from megasniff import SchemaDeflatorGenerator
from megasniff.exceptions import FieldValidationException
def test_global_strict_mode_basic():
class A:
a: int
def __init__(self, a):
self.a = a
defl = SchemaDeflatorGenerator(strict_mode=True)
fn = defl.schema_to_deflator(A)
a = fn(A(42))
assert a['a'] == 42
with pytest.raises(FieldValidationException):
fn(A(42.0))
with pytest.raises(FieldValidationException):
fn(A('42'))
with pytest.raises(FieldValidationException):
fn(A(['42']))
def test_global_strict_mode_basic_override():
class A:
a: int
def __init__(self, a):
self.a = a
defl = SchemaDeflatorGenerator(strict_mode=False)
fn = defl.schema_to_deflator(A, strict_mode_override=True)
a = fn(A(42))
assert a['a'] == 42
with pytest.raises(FieldValidationException):
fn(A(42.0))
with pytest.raises(FieldValidationException):
fn(A('42'))
with pytest.raises(FieldValidationException):
fn(A(['42']))
def test_global_strict_mode_list():
@dataclass
class A:
a: list[int]
defl = SchemaDeflatorGenerator(strict_mode=True)
fn = defl.schema_to_deflator(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]
defl = SchemaDeflatorGenerator(strict_mode=True)
fn = defl.schema_to_deflator(B)
b = fn(B([A([]), 42]))
assert len(b['b']) == 2
assert isinstance(b['b'][0], dict)
assert len(b['b'][0]['a']) == 0
assert isinstance(b['b'][1], int)
with pytest.raises(FieldValidationException):
fn(B([42.0]))
with pytest.raises(FieldValidationException):
fn(B([A([1.1])]))

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

102
uv.lock generated
View File

@@ -2,6 +2,46 @@ version = 1
revision = 2
requires-python = ">=3.13"
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "coverage"
version = "7.9.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/b7/c0465ca253df10a9e8dae0692a4ae6e9726d245390aaef92360e1d6d3832/coverage-7.9.2.tar.gz", hash = "sha256:997024fa51e3290264ffd7492ec97d0690293ccd2b45a6cd7d82d945a4a80c8b", size = 813556, upload-time = "2025-07-03T10:54:15.101Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/9d/7a8edf7acbcaa5e5c489a646226bed9591ee1c5e6a84733c0140e9ce1ae1/coverage-7.9.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:985abe7f242e0d7bba228ab01070fde1d6c8fa12f142e43debe9ed1dde686038", size = 212367, upload-time = "2025-07-03T10:53:25.811Z" },
{ url = "https://files.pythonhosted.org/packages/e8/9e/5cd6f130150712301f7e40fb5865c1bc27b97689ec57297e568d972eec3c/coverage-7.9.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82c3939264a76d44fde7f213924021ed31f55ef28111a19649fec90c0f109e6d", size = 212632, upload-time = "2025-07-03T10:53:27.075Z" },
{ url = "https://files.pythonhosted.org/packages/a8/de/6287a2c2036f9fd991c61cefa8c64e57390e30c894ad3aa52fac4c1e14a8/coverage-7.9.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae5d563e970dbe04382f736ec214ef48103d1b875967c89d83c6e3f21706d5b3", size = 245793, upload-time = "2025-07-03T10:53:28.408Z" },
{ url = "https://files.pythonhosted.org/packages/06/cc/9b5a9961d8160e3cb0b558c71f8051fe08aa2dd4b502ee937225da564ed1/coverage-7.9.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdd612e59baed2a93c8843c9a7cb902260f181370f1d772f4842987535071d14", size = 243006, upload-time = "2025-07-03T10:53:29.754Z" },
{ url = "https://files.pythonhosted.org/packages/49/d9/4616b787d9f597d6443f5588619c1c9f659e1f5fc9eebf63699eb6d34b78/coverage-7.9.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:256ea87cb2a1ed992bcdfc349d8042dcea1b80436f4ddf6e246d6bee4b5d73b6", size = 244990, upload-time = "2025-07-03T10:53:31.098Z" },
{ url = "https://files.pythonhosted.org/packages/48/83/801cdc10f137b2d02b005a761661649ffa60eb173dcdaeb77f571e4dc192/coverage-7.9.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f44ae036b63c8ea432f610534a2668b0c3aee810e7037ab9d8ff6883de480f5b", size = 245157, upload-time = "2025-07-03T10:53:32.717Z" },
{ url = "https://files.pythonhosted.org/packages/c8/a4/41911ed7e9d3ceb0ffb019e7635468df7499f5cc3edca5f7dfc078e9c5ec/coverage-7.9.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:82d76ad87c932935417a19b10cfe7abb15fd3f923cfe47dbdaa74ef4e503752d", size = 243128, upload-time = "2025-07-03T10:53:34.009Z" },
{ url = "https://files.pythonhosted.org/packages/10/41/344543b71d31ac9cb00a664d5d0c9ef134a0fe87cb7d8430003b20fa0b7d/coverage-7.9.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:619317bb86de4193debc712b9e59d5cffd91dc1d178627ab2a77b9870deb2868", size = 244511, upload-time = "2025-07-03T10:53:35.434Z" },
{ url = "https://files.pythonhosted.org/packages/d5/81/3b68c77e4812105e2a060f6946ba9e6f898ddcdc0d2bfc8b4b152a9ae522/coverage-7.9.2-cp313-cp313-win32.whl", hash = "sha256:0a07757de9feb1dfafd16ab651e0f628fd7ce551604d1bf23e47e1ddca93f08a", size = 214765, upload-time = "2025-07-03T10:53:36.787Z" },
{ url = "https://files.pythonhosted.org/packages/06/a2/7fac400f6a346bb1a4004eb2a76fbff0e242cd48926a2ce37a22a6a1d917/coverage-7.9.2-cp313-cp313-win_amd64.whl", hash = "sha256:115db3d1f4d3f35f5bb021e270edd85011934ff97c8797216b62f461dd69374b", size = 215536, upload-time = "2025-07-03T10:53:38.188Z" },
{ url = "https://files.pythonhosted.org/packages/08/47/2c6c215452b4f90d87017e61ea0fd9e0486bb734cb515e3de56e2c32075f/coverage-7.9.2-cp313-cp313-win_arm64.whl", hash = "sha256:48f82f889c80af8b2a7bb6e158d95a3fbec6a3453a1004d04e4f3b5945a02694", size = 213943, upload-time = "2025-07-03T10:53:39.492Z" },
{ url = "https://files.pythonhosted.org/packages/a3/46/e211e942b22d6af5e0f323faa8a9bc7c447a1cf1923b64c47523f36ed488/coverage-7.9.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:55a28954545f9d2f96870b40f6c3386a59ba8ed50caf2d949676dac3ecab99f5", size = 213088, upload-time = "2025-07-03T10:53:40.874Z" },
{ url = "https://files.pythonhosted.org/packages/d2/2f/762551f97e124442eccd907bf8b0de54348635b8866a73567eb4e6417acf/coverage-7.9.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cdef6504637731a63c133bb2e6f0f0214e2748495ec15fe42d1e219d1b133f0b", size = 213298, upload-time = "2025-07-03T10:53:42.218Z" },
{ url = "https://files.pythonhosted.org/packages/7a/b7/76d2d132b7baf7360ed69be0bcab968f151fa31abe6d067f0384439d9edb/coverage-7.9.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcd5ebe66c7a97273d5d2ddd4ad0ed2e706b39630ed4b53e713d360626c3dbb3", size = 256541, upload-time = "2025-07-03T10:53:43.823Z" },
{ url = "https://files.pythonhosted.org/packages/a0/17/392b219837d7ad47d8e5974ce5f8dc3deb9f99a53b3bd4d123602f960c81/coverage-7.9.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9303aed20872d7a3c9cb39c5d2b9bdbe44e3a9a1aecb52920f7e7495410dfab8", size = 252761, upload-time = "2025-07-03T10:53:45.19Z" },
{ url = "https://files.pythonhosted.org/packages/d5/77/4256d3577fe1b0daa8d3836a1ebe68eaa07dd2cbaf20cf5ab1115d6949d4/coverage-7.9.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc18ea9e417a04d1920a9a76fe9ebd2f43ca505b81994598482f938d5c315f46", size = 254917, upload-time = "2025-07-03T10:53:46.931Z" },
{ url = "https://files.pythonhosted.org/packages/53/99/fc1a008eef1805e1ddb123cf17af864743354479ea5129a8f838c433cc2c/coverage-7.9.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6406cff19880aaaadc932152242523e892faff224da29e241ce2fca329866584", size = 256147, upload-time = "2025-07-03T10:53:48.289Z" },
{ url = "https://files.pythonhosted.org/packages/92/c0/f63bf667e18b7f88c2bdb3160870e277c4874ced87e21426128d70aa741f/coverage-7.9.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2d0d4f6ecdf37fcc19c88fec3e2277d5dee740fb51ffdd69b9579b8c31e4232e", size = 254261, upload-time = "2025-07-03T10:53:49.99Z" },
{ url = "https://files.pythonhosted.org/packages/8c/32/37dd1c42ce3016ff8ec9e4b607650d2e34845c0585d3518b2a93b4830c1a/coverage-7.9.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c33624f50cf8de418ab2b4d6ca9eda96dc45b2c4231336bac91454520e8d1fac", size = 255099, upload-time = "2025-07-03T10:53:51.354Z" },
{ url = "https://files.pythonhosted.org/packages/da/2e/af6b86f7c95441ce82f035b3affe1cd147f727bbd92f563be35e2d585683/coverage-7.9.2-cp313-cp313t-win32.whl", hash = "sha256:1df6b76e737c6a92210eebcb2390af59a141f9e9430210595251fbaf02d46926", size = 215440, upload-time = "2025-07-03T10:53:52.808Z" },
{ url = "https://files.pythonhosted.org/packages/4d/bb/8a785d91b308867f6b2e36e41c569b367c00b70c17f54b13ac29bcd2d8c8/coverage-7.9.2-cp313-cp313t-win_amd64.whl", hash = "sha256:f5fd54310b92741ebe00d9c0d1d7b2b27463952c022da6d47c175d246a98d1bd", size = 216537, upload-time = "2025-07-03T10:53:54.273Z" },
{ url = "https://files.pythonhosted.org/packages/1d/a0/a6bffb5e0f41a47279fd45a8f3155bf193f77990ae1c30f9c224b61cacb0/coverage-7.9.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c48c2375287108c887ee87d13b4070a381c6537d30e8487b24ec721bf2a781cb", size = 214398, upload-time = "2025-07-03T10:53:56.715Z" },
{ url = "https://files.pythonhosted.org/packages/3c/38/bbe2e63902847cf79036ecc75550d0698af31c91c7575352eb25190d0fb3/coverage-7.9.2-py3-none-any.whl", hash = "sha256:e425cd5b00f6fc0ed7cdbd766c70be8baab4b7839e4d4fe5fac48581dd968ea4", size = 204005, upload-time = "2025-07-03T10:54:13.491Z" },
]
[[package]]
name = "hatchling"
version = "1.27.0"
@@ -17,6 +57,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/08/e7/ae38d7a6dfba0533684e0b2136817d667588ae3ec984c1a4e5df5eb88482/hatchling-1.27.0-py3-none-any.whl", hash = "sha256:d3a2f3567c4f926ea39849cdf924c7e99e6686c9c8e288ae1037c8fa2a5d937b", size = 75794, upload-time = "2024-12-15T17:08:10.364Z" },
]
[[package]]
name = "iniconfig"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
@@ -59,19 +108,31 @@ wheels = [
[[package]]
name = "megasniff"
version = "0.1.0"
version = "0.1.2"
source = { editable = "." }
dependencies = [
{ name = "hatchling" },
{ name = "jinja2" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-cov" },
]
[package.metadata]
requires-dist = [
{ name = "hatchling", specifier = ">=1.27.0" },
{ name = "jinja2", specifier = ">=3.1.6" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=8.4.1" },
{ name = "pytest-cov", specifier = ">=6.2.1" },
]
[[package]]
name = "packaging"
version = "25.0"
@@ -99,6 +160,45 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pytest"
version = "8.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" },
]
[[package]]
name = "pytest-cov"
version = "6.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage" },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/18/99/668cade231f434aaa59bbfbf49469068d2ddd945000621d3d165d2e7dd7b/pytest_cov-6.2.1.tar.gz", hash = "sha256:25cc6cc0a5358204b8108ecedc51a9b57b34cc6b8c967cc2c01a4e00d8a67da2", size = 69432, upload-time = "2025-06-12T10:47:47.684Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/16/4ea354101abb1287856baa4af2732be351c7bee728065aed451b678153fd/pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5", size = 24644, upload-time = "2025-06-12T10:47:45.932Z" },
]
[[package]]
name = "trove-classifiers"
version = "2025.5.9.12"