From 60646b94c6928c28bd235b5d4f81e72c8dc77c5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Teppo=20Per=C3=A4?= Date: Sun, 26 Apr 2015 09:33:05 +0300 Subject: [PATCH] More magic and clean up --- CHANGELOG.rst | 7 + setup.py | 3 +- src/pytraits/__init__.py | 7 +- src/pytraits/combiner.py | 9 +- src/pytraits/core/__init__.py | 4 +- src/pytraits/core/errors.py | 10 +- src/pytraits/core/magic.py | 287 ++++++++++++++++++++++++-------- src/pytraits/core/singleton.py | 2 +- src/pytraits/targets/targets.py | 1 + src/pytraits/trait_composer.py | 4 + tests/test_pytraits.py | 0 tests/unittest_typeconverted.py | 111 ++++++++++++ tests/unittest_typesafe.py | 111 ++++++++++++ 13 files changed, 474 insertions(+), 82 deletions(-) delete mode 100644 tests/test_pytraits.py create mode 100644 tests/unittest_typeconverted.py create mode 100644 tests/unittest_typesafe.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 14cf694..db0ae66 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,13 @@ Changelog ========= +0.13.0 (2015-04-25) +------------------- + - New feature: Decorator type_safe to check function arguments + - New feature: combine_class function takes name for new class as first argument + - Refactoring magic.py to look less like black magic + - Improving errors.py exception class creation to accept custom messages + 0.12.0 (2015-04-22) ------------------- - New feature: Rename of composed traits diff --git a/setup.py b/setup.py index 588ae6d..4bb7763 100644 --- a/setup.py +++ b/setup.py @@ -13,6 +13,7 @@ from setuptools import find_packages from setuptools import setup + def read(*names, **kwargs): return io.open( join(dirname(__file__), *names), @@ -22,7 +23,7 @@ def read(*names, **kwargs): setup( name='py3traits', - version='0.10.0', + version='0.13.0', license='Apache License 2', description='Trait support for Python 3', long_description='%s\n%s' % (read('README.rst'), re.sub(':obj:`~?(.*?)`', r'``\1``', read('CHANGELOG.rst'))), diff --git a/src/pytraits/__init__.py b/src/pytraits/__init__.py index 2e4abfa..52085f7 100644 --- a/src/pytraits/__init__.py +++ b/src/pytraits/__init__.py @@ -16,10 +16,11 @@ limitations under the License. ''' -from pytraits.core import Singleton +from pytraits.core import Singleton, type_safe, type_converted from pytraits.combiner import combine_class from pytraits.extendable import extendable from pytraits.trait_composer import add_traits -__version__ = "0.1.0" -__all__ = ["Singleton", "combine_class", "extendable", "add_traits"] +__version__ = "0.13.0" +__all__ = ["Singleton", "combine_class", "extendable", "add_traits", + "type_safe", "type_converted"] diff --git a/src/pytraits/combiner.py b/src/pytraits/combiner.py index 9d4de78..059a6f2 100644 --- a/src/pytraits/combiner.py +++ b/src/pytraits/combiner.py @@ -19,7 +19,7 @@ from pytraits.trait_composer import add_traits -def combine_class(*traits, **resolved_conflicts): +def combine_class(class_name, *traits, **resolved_conflicts): """ This function composes new class out of any number of traits. @@ -32,12 +32,15 @@ def combine_class(*traits, **resolved_conflicts): >>> class Three: ... def third(self): return 3 ... - >>> Combination = combine_class(One, Two, Three) + >>> Combination = combine_class("Combination", One, Two, Three) >>> instance = Combination() >>> instance.first(), instance.second(), instance.third() (1, 2, 3) + + >>> instance.__class__.__name__ + 'Combination' """ - NewClass = type("NewClass", (object,), {}) + NewClass = type(class_name, (object,), {}) add_traits(NewClass, *traits) return NewClass diff --git a/src/pytraits/core/__init__.py b/src/pytraits/core/__init__.py index 916dc19..f682b3b 100644 --- a/src/pytraits/core/__init__.py +++ b/src/pytraits/core/__init__.py @@ -18,6 +18,6 @@ from pytraits.core.singleton import Singleton from pytraits.core.utils import flatten -from pytraits.core.magic import type_converted +from pytraits.core.magic import type_safe, type_converted -__all__ = ["Singleton", "flatten", "type_converted"] +__all__ = ["Singleton", "flatten", "type_safe", "type_converted"] diff --git a/src/pytraits/core/errors.py b/src/pytraits/core/errors.py index b659a4d..09142c8 100644 --- a/src/pytraits/core/errors.py +++ b/src/pytraits/core/errors.py @@ -18,17 +18,19 @@ # Exceptions UnextendableObjectError = "Target context can be only class or instance of class" -InvalidAssignmentError = "Not possible to assign a key" -SingletonError = 'Singletons are immutable' +SingletonError = 'Singletons are immutable!' BuiltinSourceError = 'Built-in objects can not used as traits!' PropertySourceError = 'Properties can not be extended!' +TypeConversionError = 'Conversion impossible!' # Convert strings to exception objects for exception, message in dict(globals()).items(): if not exception.endswith('Error'): continue + bases = (Exception,) - attrs = {'_MSG': message, - '__str__': lambda self: self._MSG} + attrs = {'__default_msg': message, + '__init__': lambda self, msg=None: setattr(self, '__msg', msg), + '__str__': lambda self: self.__msg or self.__default_msg} globals()[exception] = type(exception, bases, attrs) diff --git a/src/pytraits/core/magic.py b/src/pytraits/core/magic.py index 80b7da5..3451d37 100644 --- a/src/pytraits/core/magic.py +++ b/src/pytraits/core/magic.py @@ -19,30 +19,217 @@ import inspect import itertools +from pytraits.core.errors import TypeConversionError -class type_converted: + +__all__ = ["type_safe", "type_converted"] + + +class ErrorMessage: + """ + Encapsulates building of error message. + """ + def __init__(self, main_msg, repeat_msg, get_func_name): + self.__errors = [] + self.__get_func_name = get_func_name + self.__main_msg = main_msg + self.__repeat_msg = repeat_msg + + def __bool__(self): + return bool(self.__errors) + + def __str__(self): + msg = [self.__main_msg.format(self.__get_func_name())] + for error in self.__errors: + msg.append(" - " + self.__repeat_msg.format(**error)) + return "\n".join(msg) + + def set_main_messsage(self, msg): + self.__main_msg = msg + + def set_repeat_message(self, msg): + self.__repeat_msg = msg + + def add(self, **kwargs): + self.__errors.append(kwargs) + + def reset(self): + self.__errors = [] + + +class type_safe: + """ + Decorator to enforce type safety. It certainly kills some ducks + but allows us also to fail fast. + + >>> @type_safe + ... def check(value: int, answer: bool, anything): + ... return value, answer, anything + ... + + >>> check("12", "false", True) + Traceback (most recent call last): + ... + TypeError: While calling check(value:int, answer:bool, anything): + - parameter 'value' had value '12' of type 'str' + - parameter 'answer' had value 'false' of type 'str' + + >>> check(1000, True) + Traceback (most recent call last): + ... + TypeError: check() missing 1 required positional argument: 'anything' + """ + def __init__(self, function): + self._function = function + self._specs = inspect.getfullargspec(self._function) + self._self = None + self._errors = ErrorMessage( + 'While calling {}:', + "parameter '{name}' had value '{value}' of type '{typename}'", + self.function_signature) + + def __get__(self, instance, clazz): + """ + Stores calling instances and returns this decorator object as function. + """ + # In Python, every function is a property. Before Python invokes function, + # it must access the function using __get__, where it can deliver the calling + # object. After the __get__, function is ready for being invoked by __call__. + self._self = instance + return self + + def iter_positional_args(self, args): + """ + Yields type, name, value combination of function arguments. + """ + # specs.args contains all arguments of the function. Loop here all + # argument names and their values putting them together. If there + # are arguments missing values, fill them with None. + for name, val in itertools.zip_longest(self._specs.args, args, fillvalue=None): + # __annotations__ is a dictionary of argument name and annotation. + # We accept empty annotations, in which case the argument has no + # type requirement. + yield self._function.__annotations__.get(name, None), name, val + + def function_signature(self): + """ + Returns signature and class of currently invoked function. + + >>> @type_converted + ... def test(value: int, answer: bool): pass + >>> test.function_signature() + 'test(value:int, answer:bool)' + """ + sig = str(inspect.signature(self._function)) + name = self._function.__name__ + if self._self: + return "%s.%s%s" % (self._self.__class__.__name__, name, sig) + else: + return "%s%s" % (name, sig) + + def _analyze_args(self, args): + """ + Invoked by __call__ in order to work with positional arguments. + + This function does the actual work of evaluating arguments against + their annotations. Any deriving class can override this function + to do different kind of handling for the arguments. Overriding function + must return list of arguments that will be used to call the decorated + function. + + @param args: Arguments given for the function. + @return same list of arguments given in parameter. + """ + for arg_type, arg_name, arg_value in self.iter_positional_args(args): + if not arg_type or isinstance(arg_value, arg_type): + continue + + self._errors.add( + typename=type(arg_value).__name__, + name=arg_name, + value=arg_value) + + if self._errors: + raise TypeError(str(self._errors)) + + return args + + def __match_arg_count(self, args): + """ + Verifies that proper number of arguments are given to function. + """ + # With default values this verification is bit tricky. In case + # given arguments match with number of arguments in function signature, + # we can proceed. + if len(args) == len(self._specs.args): + return True + + # It's possible to have less arguments given than defined in function + # signature in case any default values exist. + if len(args) - len(self._specs.defaults or []) == len(self._specs.args): + return True + + # When exceeding number of args, also check if function accepts + # indefinite number of positional arguments. + if len(args) > len(self._specs.args) and self._specs.varargs: + return True + + # We got either too many arguments or too few. + return False + + def __call__(self, *args, **kwargs): + """ + Converts annotated types into proper type and calls original function. + """ + self._errors.reset() + + # Methods require instance of the class to be first argument. We + # stored it in __get__ and now add it to argument list so that + # function can be invoked correctly. + if self._self: + args = (self._self, ) + args + + # Before doing any type checks, make sure argument count matches. + if self.__match_arg_count(args): + args = self._analyze_args(args) + + return self._function(*args, **kwargs) + + +class type_converted(type_safe): """ Decorator to enforce types and do auto conversion to values. >>> @type_converted - ... def convert(value: int, answer: bool): - ... return value, answer + ... def convert(value: int, answer: bool, anything): + ... return value, answer, anything ... - >>> convert("12", "false") - (12, False) + >>> convert("12", "false", None) + (12, False, None) >>> class Example: ... @type_converted - ... def convert(self, value: int, answer: bool): - ... return value, answer + ... def convert(self, value: int, answer: bool, anything): + ... return value, answer, anything ... - >>> Example().convert("12", 0) - (12, False) + >>> Example().convert("12", 0, "some value") + (12, False, 'some value') + + >>> Example().convert(None, None, None) + Traceback (most recent call last): + ... + pytraits.core.errors.TypeConversionError: While calling Example.convert(self, value:int, answer:bool, anything): + - got arg 'value' as 'None' of type 'NoneType' which cannot be converted to 'int' + - got arg 'answer' as 'None' of type 'NoneType' which cannot be converted to 'bool' """ def __init__(self, function): - self.__function = function - self.__self = None + super().__init__(function) self.__converters = {bool: self.boolean_conversion} + self._errors = ErrorMessage( + 'While calling {}:', + "got arg '{name}' as '{value}' of type '{typename}' " + "which cannot be converted to '{expectedtype}'", + self.function_signature) def convert(self, arg_type, arg_name, arg_value): """ @@ -53,23 +240,15 @@ def convert(self, arg_type, arg_name, arg_value): return arg_value try: - try: - return self.__converters[arg_type](arg_value) - except KeyError: - return arg_type(arg_value) - except: - msg = "While calling %s, offered value for argument '%s' was '%s' "\ - "which cannot be converted to %s!" - params = (self.function_signature(), arg_name, arg_value, arg_type) - - # TODO: Convert or exceptions to own types. - raise TypeError(msg % params) + return self.__converters[arg_type](arg_value) + except KeyError: + return arg_type(arg_value) def boolean_conversion(self, value): """ Convert given value to boolean. - >>> conv = type_converted(None) + >>> conv = type_converted(lambda self: None) >>> conv.boolean_conversion("True"), conv.boolean_conversion("false") (True, False) @@ -91,57 +270,29 @@ def boolean_conversion(self, value): if value == 1: return True - raise TypeError() # This will be caught by convert method. + raise TypeConversionError() # This will be caught by convert method. - def __get__(self, instance, clazz): - """ - Stores calling instances and returns this decorator object as function. - """ - # In Python, every function is a property. Before Python invokes function, - # it must access the funtion using __get__, where it can deliver the calling - # object. After the __get__, function is ready for being invoked by __call__. - self.__self = instance - return self - - def iter_positional_args(self, args): - """ - Yields type, name, value combination of function arguments. - """ - specs = inspect.getfullargspec(self.__function) - - # Methods require instance of the class to be first argument. We - # stored it in __get__ and now add it to argument list so that - # function can be invoked correctly. - if self.__self: - args = (self.__self, ) + args - - for spec, arg in itertools.zip_longest(specs.args, args, fillvalue=None): - yield self.__function.__annotations__.get(spec, None), spec, arg - - def function_signature(self): - """ - Returns signature and class of currently invoked function. - - >>> @type_converted - ... def test(value: int, answer: bool): pass - >>> test.function_signature() - 'test(value:int, answer:bool)' - """ - sig = str(inspect.signature(self.__function)) - name = self.__function.__name__ - if self.__self: - return "%s.%s%s" % (self.__self.__class__.__name__, name, sig) - else: - return "%s%s" % (name, sig) - - def __call__(self, *args, **kwargs): + def _analyze_args(self, args): """ Converts annotated types into proper type and calls original function. """ + self._errors.reset() new_args = [] + for arg_type, arg_name, arg_value in self.iter_positional_args(args): - new_args.append(self.convert(arg_type, arg_name, arg_value)) - return self.__function(*new_args, **kwargs) + try: + new_args.append(self.convert(arg_type, arg_name, arg_value)) + except (TypeConversionError, TypeError): + self._errors.add( + name=arg_name, + value=arg_value, + typename=type(arg_value).__name__, + expectedtype=arg_type.__name__) + + if self._errors: + raise TypeConversionError(str(self._errors)) + + return new_args if __name__ == "__main__": diff --git a/src/pytraits/core/singleton.py b/src/pytraits/core/singleton.py index d2c83cc..b32192f 100644 --- a/src/pytraits/core/singleton.py +++ b/src/pytraits/core/singleton.py @@ -47,7 +47,7 @@ class Singleton(type): >>> MySingleton().new_item = False Traceback (most recent call last): ... - pytraits.core.errors.SingletonError: Singletons are immutable + pytraits.core.errors.SingletonError: Singletons are immutable! """ def __call__(self, *args, **kwargs): try: diff --git a/src/pytraits/targets/targets.py b/src/pytraits/targets/targets.py index e544811..b818348 100644 --- a/src/pytraits/targets/targets.py +++ b/src/pytraits/targets/targets.py @@ -19,6 +19,7 @@ import inspect from pytraits.core.errors import UnextendableObjectError +from pytraits.core.magic import type_safe from pytraits.sources import Traits diff --git a/src/pytraits/trait_composer.py b/src/pytraits/trait_composer.py index b0cbfc4..edf845e 100644 --- a/src/pytraits/trait_composer.py +++ b/src/pytraits/trait_composer.py @@ -23,6 +23,10 @@ class TraitComposer(metaclass=Singleton): """ Main class that handles composing traits into target object. + This object is singleton as there really can be only one. + + >>> id(TraitComposer()) == id(TraitComposer()) + True >>> class ExampleClass: ... def example_method(self): diff --git a/tests/test_pytraits.py b/tests/test_pytraits.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/unittest_typeconverted.py b/tests/unittest_typeconverted.py new file mode 100644 index 0000000..fddd150 --- /dev/null +++ b/tests/unittest_typeconverted.py @@ -0,0 +1,111 @@ +import unittest + +from pytraits import type_converted +from pytraits.core.magic import TypeConversionError + + +class TestTypeConverted(unittest.TestCase): + def test_shows_unassigned_arguments_error_for_omitted_arguments(self): + # We need to make sure that when user misses argument from the + # function call, we show proper error message. + @type_converted + def converted(existing, missing): + pass + + with self.assertRaisesRegex(TypeError, ".*missing 1 required.*"): + converted(True) + + def test_shows_unassigned_arguments_error_for_ommitted_arguments_with_type(self): + # Even if argument has any arguments with annotated type, we still + # need to give proper error message, when that argument has been + # omitted. + @type_converted + def converted(existing, missing: int): + pass + + with self.assertRaisesRegex(TypeError, ".*missing 1 required.*"): + converted(True) + + def test_uses_default_value_for_omitted_arguments(self): + # Missing arguments with default values should be properly used when + # arguments are omitted. + @type_converted + def converted(existing, missing_with_default=42): + return missing_with_default + + self.assertEqual(converted(True), 42) + + def test_uses_default_value_for_omitted_arguments_with_type(self): + # Missing arguments with default values should be properly used when + # arguments are omitted even when there are annotated arguments. + @type_converted + def converted(existing, missing_with_default: int=42): + return missing_with_default + + self.assertEqual(converted(True), 42) + + def test_ignores_default_value_when_argument_given_with_type(self): + # Missing arguments with default values should be properly used when + # arguments are omitted even when there are annotated arguments. + @type_converted + def converted(existing, missing_with_default: int=42): + return missing_with_default + + self.assertEqual(converted(True, "52"), 52) + + def test_handles_properly_tuple_arguments(self): + @type_converted + def converted(existing, *remainder): + return existing + + self.assertEqual(converted(True), True) + + def test_handles_properly_tuple_arguments_with_type(self): + @type_converted + def converted(existing: bool, *remainder): + return existing + + self.assertEqual(converted(True), True) + + def test_handles_properly_tuple_arguments_with_type(self): + @type_converted + def converted(existing: bool, *remainder): + return existing + + with self.assertRaisesRegex(TypeConversionError, "While calling.*"): + converted(2, "tuple", "args") + + def test_shows_proper_error_when_too_many_args_given(self): + @type_converted + def converted(existing): + return missing_with_default + + with self.assertRaisesRegex(TypeError, ".*takes 1 positional.*"): + self.assertEqual(converted(True, 52), 52) + + def test_shows_proper_error_when_too_many_args_given_with_type(self): + @type_converted + def converted(existing: bool): + return missing_with_default + + with self.assertRaisesRegex(TypeError, ".*takes 1 positional.*"): + self.assertEqual(converted(True, 52), 52) + + def test_shows_proper_error_when_too_many_args_given_with_default(self): + @type_converted + def converted(existing=False): + return missing_with_default + + with self.assertRaisesRegex(TypeError, ".*takes from 0 to 1 positional.*"): + self.assertEqual(converted(True, 52), 52) + + def test_shows_proper_error_when_too_many_args_given_with_type_and_default(self): + @type_converted + def converted(existing: bool=False): + return missing_with_default + + with self.assertRaisesRegex(TypeError, ".*takes from 0 to 1 positional.*"): + self.assertEqual(converted(True, 52), 52) + +if __name__ == '__main__': + unittest.main() diff --git a/tests/unittest_typesafe.py b/tests/unittest_typesafe.py new file mode 100644 index 0000000..010bb2d --- /dev/null +++ b/tests/unittest_typesafe.py @@ -0,0 +1,111 @@ +import unittest + +from pytraits import type_safe + + +class TestTypeSafe(unittest.TestCase): + def test_shows_unassigned_arguments_error_for_omitted_arguments(self): + # We need to make sure that when user misses argument from the + # function call, we show proper error message. + @type_safe + def checked(existing, missing): + pass + + with self.assertRaisesRegex(TypeError, ".*missing 1 required.*"): + checked(True) + + def test_shows_unassigned_arguments_error_for_ommitted_arguments_with_type(self): + # Even if argument has any arguments with annotated type, we still + # need to give proper error message, when that argument has been + # omitted. + @type_safe + def checked(existing, missing: int): + pass + + with self.assertRaisesRegex(TypeError, ".*missing 1 required.*"): + checked(True) + + def test_uses_default_value_for_omitted_arguments(self): + # Missing arguments with default values should be properly used when + # arguments are omitted. + @type_safe + def checked(existing, missing_with_default=42): + return missing_with_default + + self.assertEqual(checked(True), 42) + + def test_uses_default_value_for_omitted_arguments_with_type(self): + # Missing arguments with default values should be properly used when + # arguments are omitted even when there are annotated arguments. + @type_safe + def checked(existing, missing_with_default: int=42): + return missing_with_default + + self.assertEqual(checked(True), 42) + + def test_ignores_default_value_when_argument_given_with_type(self): + # Missing arguments with default values should be properly used when + # arguments are omitted even when there are annotated arguments. + @type_safe + def checked(existing, missing_with_default: int=42): + return missing_with_default + + self.assertEqual(checked(True, 52), 52) + + def test_handles_properly_tuple_arguments(self): + @type_safe + def checked(existing, *remainder): + return existing + + self.assertEqual(checked(True), True) + + def test_handles_properly_tuple_arguments_with_type(self): + @type_safe + def checked(existing: bool, *remainder): + return existing + + self.assertEqual(checked(True), True) + + def test_handles_properly_tuple_arguments_with_type(self): + @type_safe + def checked(existing: bool, *remainder): + return existing + + with self.assertRaisesRegex(TypeError, "While calling.*"): + checked(2, "tuple", "args") + + def test_shows_proper_error_when_too_many_args_given(self): + @type_safe + def checked(existing): + return missing_with_default + + with self.assertRaisesRegex(TypeError, ".*takes 1 positional.*"): + self.assertEqual(checked(True, 52), 52) + + def test_shows_proper_error_when_too_many_args_given_with_type(self): + @type_safe + def checked(existing: bool): + return missing_with_default + + with self.assertRaisesRegex(TypeError, ".*takes 1 positional.*"): + self.assertEqual(checked(True, 52), 52) + + def test_shows_proper_error_when_too_many_args_given_with_default(self): + @type_safe + def checked(existing=False): + return missing_with_default + + with self.assertRaisesRegex(TypeError, ".*takes from 0 to 1 positional.*"): + self.assertEqual(checked(True, 52), 52) + + def test_shows_proper_error_when_too_many_args_given_with_type_and_default(self): + @type_safe + def checked(existing: bool=False): + return missing_with_default + + with self.assertRaisesRegex(TypeError, ".*takes from 0 to 1 positional.*"): + self.assertEqual(checked(True, 52), 52) + + +if __name__ == '__main__': + unittest.main()