diff --git a/easypy/collections.py b/easypy/collections.py index 79db50fc..5d91fbd0 100755 --- a/easypy/collections.py +++ b/easypy/collections.py @@ -1,9 +1,10 @@ from __future__ import absolute_import import collections from numbers import Integral -from itertools import chain, islice +from itertools import chain, islice, count from functools import partial import random +from contextlib import contextmanager from .predicates import make_predicate from .tokens import UNIQUE @@ -684,3 +685,34 @@ def append(self, item): super().append(item) if len(self) > self.size: self.pop(0) + + +class ContextCollection(object): + """ + A collection where you add and remove things using context managers:: + + cc = ContextCollection() + + assert list(cc) == [] + with cc.added(10): + assert list(cc) == [10] + with cc.added(20): + assert list(cc) == [10, 20] + assert list(cc) == [10] + assert list(cc) == [] + """ + def __init__(self): + self._items = PythonOrderedDict() + self._index_generator = count(1) + + def __iter__(self): + return iter(self._items.values()) + + @contextmanager + def added(self, value): + index = next(self._index_generator) + try: + self._items[index] = value + yield index + finally: + del self._items[index] diff --git a/easypy/decorations.py b/easypy/decorations.py index f3489081..e61bcc22 100644 --- a/easypy/decorations.py +++ b/easypy/decorations.py @@ -234,3 +234,28 @@ def foo(self): def wrapper(func): return LazyDecoratorDescriptor(decorator_factory, func) return wrapper + + +class RegistryDecorator(object): + """ + A factory for simple decorators that register functions by name:: + + register = RegistryDecorator() + + @register + def foo(): + return 'I am foo' + + @register + def bar(): + return 'I am bar' + + assert register.registry['foo']() == 'I am foo' + assert register.registry['bar']() == 'I am bar' + """ + def __init__(self): + self.registry = {} + + def __call__(self, fn): + self.registry[fn.__name__] = fn + return fn diff --git a/easypy/typed_struct.py b/easypy/typed_struct.py index fee1783f..7a66e054 100644 --- a/easypy/typed_struct.py +++ b/easypy/typed_struct.py @@ -1,9 +1,12 @@ from copy import deepcopy +from contextlib import contextmanager +from itertools import count from .exceptions import TException from .tokens import AUTO, MANDATORY -from .collections import ListCollection, PythonOrderedDict, iterable +from .collections import ListCollection, PythonOrderedDict, iterable, ContextCollection from .bunch import Bunch, bunchify +from .decorations import RegistryDecorator class InvalidFieldType(TException): @@ -98,6 +101,12 @@ def __init__(self, type, *, default=MANDATORY, preprocess=None, meta=Bunch()): >>> a.default = 12 >>> Foo() Foo(a=12) + + Alternatively, use the ``DEFAULT`` context manager:: + + class Foo(TypedStruct): + with DEFAULT(12): + a = int """ # NOTE: _validate_type() will be also be called in _process_new_value() @@ -107,6 +116,36 @@ def __init__(self, type, *, default=MANDATORY, preprocess=None, meta=Bunch()): # specific FieldTypeMismatch. self.preprocess = preprocess or self._validate_type self.meta = Bunch(meta) + """ + Metadata for the field, to be used for reflection + + >>> class Foo(TypedStruct): + >>> a = int + >>> a.meta.caption = 'Field A' + >>> + >>> b = int + >>> b.meta.caption = 'Field B' + >>> + >>> Foo.a.meta + Bunch(caption='Field A') + >>> Foo.b.meta + Bunch(caption='Field B') + >>> foo = Foo(a=1, b=2) + >>> + >>> for k, v in foo.items(): + >>> caption = getattr(type(foo), k).meta.caption + >>> print('%s:\t%s' % (caption, v)) + Field A: 1 + Field B: 2 + + Alternatively, use the ``META`` context manager:: + + class Foo(TypedStruct): + with META(caption='Field A'): + a = int + with META(caption='Field B'): + b = int + """ self.name = None if issubclass(self.type, TypedStruct): @@ -141,6 +180,12 @@ def add_validation(self, predicate, ex_type, *ex_args, **ex_kwargs): Foo(a=5) >>> Foo(a=15) ValueError: value for `a` is too big + + Alternatively, use the ``VALIDATION`` context manager:: + + class Foo(TypedStruct): + with VALIDATION(lambda value: value < 10, ValueError, 'value for `a` is too big'): + a = int """ orig_preprocess = self.preprocess @@ -173,6 +218,13 @@ def add_conversion(self, predicate, conversion): Foo(a=2) >>> Foo(a=[10, 20, 30]) # the list has 3 items Foo(a=3) + + + Alternatively, use the ``CONVERSION`` context manager:: + + class Foo(TypedStruct): + with CONVERSION(str, int), CONVERSION(list, len): + a = int """ if isinstance(predicate, type): typ = predicate @@ -211,6 +263,12 @@ def convertible_from(self, *predicates, conversion=AUTO): Foo(a=2) >>> Foo(a=3.0) Foo(a=3) + + Alternatively, use the ``CONVERTIBLE_FROM`` context manager:: + + class Foo(TypedStruct): + CONVERTIBLE_FROM(str, float): + a = int """ if conversion is AUTO: conversion = self.type @@ -382,16 +440,65 @@ def altered_dct_gen(): class _TypedStructDslDict(PythonOrderedDict): + field_context = RegistryDecorator() + + def __init__(self): + super().__init__() + self.active_field_contexts = ContextCollection() + + def __getitem__(self, name): + try: + field_context = self.field_context.registry[name] + except KeyError: + return super().__getitem__(name) + else: + return field_context.__get__(self) + def __setitem__(self, name, value): if isinstance(value, Field): value = value._named(name) + self.apply_field_contexts(value) else: try: value = Field(value)._named(name) except InvalidFieldType: pass + self.apply_field_contexts(value) return super().__setitem__(name, value) + def apply_field_contexts(self, field): + for field_context in self.active_field_contexts: + field_context(field) + pass + + @field_context + @contextmanager + def FIELD_SETTING(self, dlg): + with self.active_field_contexts.added(dlg): + yield + + @field_context + def DEFAULT(self, default): + def applier(field): + field.default = default + return self.FIELD_SETTING(applier) + + @field_context + def VALIDATION(self, predicate, ex_type, *ex_args, **ex_kwargs): + return self.FIELD_SETTING(lambda field: field.add_validation(predicate, ex_type, *ex_args, **ex_kwargs)) + + @field_context + def CONVERSION(self, predicate, conversion): + return self.FIELD_SETTING(lambda field: field.add_conversion(predicate, conversion)) + + @field_context + def CONVERTIBLE_FROM(self, *predicates, conversion=AUTO): + return self.FIELD_SETTING(lambda field: field.convertible_from(*predicates, conversion=conversion)) + + @field_context + def META(self, **kwargs): + return self.FIELD_SETTING(lambda field: field.meta.update(kwargs)) + class TypedStruct(dict, metaclass=TypedStructMeta): """ @@ -422,6 +529,33 @@ class Foo(TypedStruct): a = int a.default = 20 a.convertible_from(str, float) + + Alternatively, you can use the special context managers:: + + from easypy.typed_struct import TypedStruct + + class Foo(TypedStruct): + with DEFAULT(20), CONVERTIBLE_FROM(str, float): + a = int + + If you have a complex setting you need to apply to the fields you can use + the FIELD_SETTING context manager:: + + from easypy.typed_struct import TypedStruct + + def my_field_setting(field): + field.default = 50 + field.convertible_from(str, float) + field.add_validation(lambda n: 0 <= n <= 100, ValueError, 'number not in range') + + class Foo(TypedStruct): + with FIELD_SETTING(my_field_setting): + a = int + b = int + c = int + + You do not need to import these special context managers - they will + automatically be there when you define the typed struct. """ @classmethod diff --git a/tests/test_collections.py b/tests/test_collections.py index 087488bb..0a21fd18 100644 --- a/tests/test_collections.py +++ b/tests/test_collections.py @@ -1,8 +1,10 @@ import pytest from easypy.collections import separate from easypy.collections import ListCollection, partial_dict, UNIQUE, ObjectNotFound +from easypy.collections import ContextCollection from easypy.bunch import Bunch from collections import Counter +from contextlib import ExitStack class O(Bunch): @@ -98,3 +100,29 @@ def test_collections_slicing(): assert L[-2:] == list('ef') assert L[::2] == list('ace') assert L[::-2] == list('fdb') + + +def test_context_collection(): + cc = ContextCollection() + + assert list(cc) == [] + with cc.added(10): + assert list(cc) == [10] + with cc.added(20): + assert list(cc) == [10, 20] + assert list(cc) == [10] + assert list(cc) == [] + + with ExitStack() as stack: + with cc.added(30): + assert list(cc) == [30] + + stack.enter_context(cc.added(40)) + assert list(cc) == [30, 40] + + assert list(cc) == [40] + + with cc.added(50): + assert list(cc) == [40, 50] + assert list(cc) == [40] + assert list(cc) == [] diff --git a/tests/test_decorations.py b/tests/test_decorations.py index 07287dcd..5bb82a6a 100644 --- a/tests/test_decorations.py +++ b/tests/test_decorations.py @@ -3,6 +3,7 @@ from functools import wraps from easypy.decorations import deprecated_arguments, kwargs_resilient, lazy_decorator +from easypy.decorations import RegistryDecorator def test_deprecated_arguments(): @@ -111,3 +112,18 @@ def foo(self): foo.num = 20 assert foo.foo() == 21 assert foo.foo.__name__ == 'foo + 20' + + +def test_registry_decorator(): + register = RegistryDecorator() + + @register + def foo(): + return 'I am foo' + + @register + def bar(): + return 'I am bar' + + assert register.registry['foo']() == 'I am foo' + assert register.registry['bar']() == 'I am bar' diff --git a/tests/test_typed_struct.py b/tests/test_typed_struct.py index fa685798..2bd50419 100644 --- a/tests/test_typed_struct.py +++ b/tests/test_typed_struct.py @@ -512,3 +512,62 @@ class Foo(ts.TypedStruct): b.convertible_from(str) assert Foo(b='2.3').to_dict() == dict(a=1, b=2.3) + + +def test_typed_struct_context_managers_dsl(): + class Foo(ts.TypedStruct): + a = int + + with FIELD_SETTING(lambda f: f.convertible_from(str)): + b = int + + c = int + + assert Foo(a=1, b='2', c=3) == Foo(a=1, b=2, c=3) + + # Not applied on fields before the CM + with pytest.raises(ts.FieldTypeMismatch): + Foo(a='1', b='2', c=3) + + # Not applied on fields after the CM + with pytest.raises(ts.FieldTypeMismatch): + Foo(a=1, b='2', c='3') + + class Bar(ts.TypedStruct): + with DEFAULT(10): + a = int + with DEFAULT(20): + b = int + c = int + + assert Bar().to_dict() == dict(a=10, b=20, c=10) + + class OutOfRangeError(Exception): + pass + + class Baz(ts.TypedStruct): + with VALIDATION(lambda v: 0 <= v < 10, OutOfRangeError): + a = int + + assert Baz(a=5).to_dict() == dict(a=5) + with pytest.raises(OutOfRangeError): + Baz(a=15) + + class Qux(ts.TypedStruct): + with CONVERTIBLE_FROM(str), CONVERSION(list, len): + a = int + + assert Qux(a='1') == Qux(a=1) + assert Qux(a=[1, 2, 3]) == Qux(a=3) + + class Quux(ts.TypedStruct): + with META(x=1, y=2): + a = int + with META(x=3): + b = int + with META(y=4): + c = int + + assert Quux.a.meta == dict(x=1, y=2) + assert Quux.b.meta == dict(x=3, y=2) + assert Quux.c.meta == dict(x=1, y=4)