diff options
Diffstat (limited to 'check_loaders.py')
-rwxr-xr-x | check_loaders.py | 228 |
1 files changed, 228 insertions, 0 deletions
diff --git a/check_loaders.py b/check_loaders.py new file mode 100755 index 0000000..c1e841c --- /dev/null +++ b/check_loaders.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +""" +# Comparison of type-safe loaders for Python + +When loading data with json.load or toml.load you get unstructured data. +There exist several libraries to help you you check that this unstructured data +matches your expected structure, without having to write a bunch of boilerplate +code. This Python script compares these libraries. + +* typedload (??) +* apischema (??) +* serdelicacy (??) +* perde (??) +* typical (??) +* cattrs (??) +* pyserde (??) + +For other comparisons see https://quackmark.push-f.com/. + +""" +from types import ModuleType +from typing import Optional, Literal +import typing, msgpack + +module: Optional[ModuleType] = None + +class NotApplicable(Exception): ... + +def perde(m, v, t): + try: + encoded = msgpack.dumps(v) + except TypeError: + raise NotApplicable + return m.msgpack.loads_as(t, encoded) + +LOADERS = { + 'typedload': lambda m, v, t: m.load(v, t, basiccast=False, failonextra=True), + 'apischema': lambda m, v, t: m.deserialize(t, v), + 'serdelicacy': lambda m, v, t: m.load(v, t), + 'perde': perde, + 'typic': lambda m, v, t: m.protocol(t).transmute(v), + 'cattrs': lambda m, v, t: m.structure(v, t), + 'pyserde': lambda m, v, t: m.from_dict(t, v), +} + +def load(v, t): + if module: + return LOADERS[module.__name__](module, v, t) + +Err = ... # denotes that a function raised an exception + +# booleans +load(True, bool) == True +load(False, bool) == False +load(None, bool) == Err +load(1, bool) == Err +load(0, bool) == Err +load('foo', bool) == Err +load('false', bool) == Err + +# strings, bytes and floats +load(b'test', str) == Err +load(b'test', bytes) == b'test' +load('inf', float) == Err +load('nan', float) == Err + +# byte arrays +load(b'test', bytearray) == bytearray(b'test') + +# literals + +YesOrNo = Literal['yes', 'no'] +load('yes', YesOrNo) == 'yes' +load('Yes', YesOrNo) == Err + +# optionals +load(None, Optional[int]) == None +load(3, Optional[int]) == 3 + +# enums +import enum + +class Answer(enum.Enum): + Yes = 1 + No = 2 + +class AnswerNum(enum.IntEnum): + Yes = 1 + No = 2 + +class Color(enum.IntFlag): + Red = enum.auto() + Green = enum.auto() + Blue = enum.auto() + +color = Color.Red | Color.Blue + +load('Yes', Answer) == Err +load(1, Answer) == Answer.Yes +load('Yes', AnswerNum) == Err +load(1, AnswerNum) == AnswerNum.Yes +load(int(color), Color) == color + +# newtype + +MyInt = typing.NewType('MyInt', int) + +load(3, MyInt) == MyInt(3) + +# collections +load([1,2,3], tuple[int, ...]) == (1,2,3) +load([1,2,3], set[int]) == {1,2,3} +load([1,2,3], frozenset[int]) == frozenset((1,2,3)) +load({1,2,3}, list[int]) == [1,2,3] +load((1,2,3), list[int]) == [1,2,3] +load([(1, 1)], dict[int, int]) == Err + +# named tuples + +class NT(typing.NamedTuple): + x: int = 0 + +load({'x': 1}, NT) == NT(1) +load({}, NT) == NT() +load({'y': 1}, NT) == Err + +# dataclasses +from dataclasses import dataclass, InitVar + +@dataclass +class DC: + x: int = 0 + i: InitVar[int] = 0 + + def __post_init__(self, i): + self.x += i + +load({'x': 1}, DC) == DC(1) +load({}, DC) == DC() +load({'y': 1}, DC) == Err +load({'i': 5}, DC) == DC(5) + +# TypedDict + +class TD(typing.TypedDict): + x: int + +load({'x': 1}, TD) == {'x': 1} +load({'x': 'x'}, TD) == Err +load({'y': 1}, TD) == Err + +# objects from strings +from pathlib import Path +from ipaddress import IPv4Address, IPv6Interface + +class Foo: + def __init__(self, x: int): + self.x = x + +load('test', Path) == Path('test') +load('127.0.0.1', IPv4Address) == IPv4Address('127.0.0.1') +load('::1.2.3.4/24', IPv6Interface) == IPv6Interface('::1.2.3.4/24') +load('foo', Foo) == Err + +from uuid import UUID +from decimal import Decimal +from datetime import datetime +my_id = '12345678123456781234567812345678' +now = datetime.now() + +load(my_id, UUID) == UUID(my_id) +load(1, Decimal) == Decimal(1) +load(str(now), datetime) == now + +# run method +import html, importlib, importlib.metadata, inspect, sys, re + +def run(): + global module + print('<meta charset=utf-8><pre style="font-family: \'Source Code Pro\', monospace; width: 90ch; margin: 0 auto;">') + lines = iter(inspect.getsourcelines(sys.modules[__name__])[0]) + while line := next(lines, None): + if line.startswith('# '): + print('<h2 style="margin:0; font-size: inherit">' + html.escape(line.strip()), '</h2>', end='') + elif line.startswith('* ') and '(' in line: + pkg_name = line.split()[1] + metadata = importlib.metadata.metadata(pkg_name) + link = '<a href="{}">{}</a>'.format(html.escape(metadata['Home-page']), pkg_name) + print(line.split('(')[0].replace(pkg_name, link) + f'</a>({metadata.get("Version")})') + elif line.startswith('load('): + tests = [line] + while (l := next(lines)).startswith('load('): + tests.append(l) + print('<table>') + print('<tr><td><td>#') + for loader in LOADERS: + print('<td style="padding: 0 0.5em; text-align: center">', loader) + print('</tr>') + for test in tests: + print('<tr>') + print('<td style="padding-right: 1em">', html.escape(test), '<td>#') + for loader in LOADERS: + module = importlib.import_module(loader) + load_call, expected = test.strip().split(' == ') + print('<td align=center>') + try: + result = eval(load_call) + if result == eval(expected): + print('✅') + else: + print(html.escape(repr(result))) + except NotApplicable: + print('N/A') + except Exception as e: + if expected == 'Err': + print('✅') + else: + print('<abbr title="{}">Error</abbr>'\ + .format(html.escape(str(e)))) + pass + print('</tr>') + print('</table>') + else: + print(re.sub("https://[a-z./-]+/", lambda url: f'<a href="{url.group()}">{url.group()}</a>', html.escape(line)), end='') + print('<pre>') + +if __name__ == '__main__': + run() |