summaryrefslogtreecommitdiff
path: root/check_loaders.py
diff options
context:
space:
mode:
authorMartin Fischer <martin@push-f.com>2022-02-20 12:55:32 +0100
committerMartin Fischer <martin@push-f.com>2022-02-23 15:59:32 +0100
commit0b6c028fd4be048bde9369b870e6fe4b80577886 (patch)
treef3948a5b35156e0e4c00f32cd577067eb31ce497 /check_loaders.py
publish
Diffstat (limited to 'check_loaders.py')
-rwxr-xr-xcheck_loaders.py228
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()