#!/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('
')
    lines = iter(inspect.getsourcelines(sys.modules[__name__])[0])
    while line := next(lines, None):
        if line.startswith('# '):
            print('

' + html.escape(line.strip()), '

', end='') elif line.startswith('* ') and '(' in line: pkg_name = line.split()[1] metadata = importlib.metadata.metadata(pkg_name) link = '{}'.format(html.escape(metadata['Home-page']), pkg_name) print(line.split('(')[0].replace(pkg_name, link) + f'({metadata.get("Version")})') elif line.startswith('load('): tests = [line] while (l := next(lines)).startswith('load('): tests.append(l) print('') print('') for test in tests: print('') print('') print('
#') for loader in LOADERS: print('', loader) print('
', html.escape(test), '#') for loader in LOADERS: module = importlib.import_module(loader) load_call, expected = test.strip().split(' == ') print('') 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('Error'\ .format(html.escape(str(e)))) pass print('
') else: print(re.sub("https://[a-z./-]+/", lambda url: f'{url.group()}', html.escape(line)), end='') print('
')

if __name__ == '__main__':
    run()