├── .gitignore ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── mutations ├── __init__.py ├── core.py ├── error.py ├── fields.py ├── metadata.py ├── util.py └── validators.py ├── pytest.ini ├── requirements.txt ├── setup.cfg ├── setup.py ├── tests ├── test_basic.py ├── test_complex.py ├── test_error.py └── test_resilience.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | .static_storage/ 56 | .media/ 57 | local_settings.py 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | .pytest_cache/ 106 | 107 | snapshots/ 108 | 109 | .vscode/ 110 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Omar Bassam Bohsali 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: tests 2 | 3 | tests: 4 | python -m pytest 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mutations 2 | 3 | [![pypi-version]][pypi] 4 | 5 | Compose your business logic into commands that sanitize and validate input. 6 | 7 | ## Install 8 | 9 | ```bash 10 | $ pip install mutations 11 | ``` 12 | 13 | ## How it Works: 14 | 15 | 1. Subclass `mutations.Mutation` 16 | 2. Define your inputs. 17 | 3. Define an `execute` method in your command. 18 | 4. Run it, like this: `SimpleMutation.run(foo='bar')` 19 | 20 | To learn more, see [this blog post](https://omarish.com/2018/02/17/mutations.html). 21 | 22 | ## Example 23 | 24 | ```python 25 | import mutations 26 | 27 | class UserSignup(mutations.Mutation): 28 | """Define the inputs to your mutation here. """ 29 | email = mutations.fields.CharField(required=True) 30 | full_name = mutations.fields.CharField(required=True) 31 | send_welcome_email = mutations.fields.Boolean(required=False, default=True) 32 | 33 | def validate_email_address(self): 34 | """Custom validation for a field. 35 | 36 | If you encounter any validation errors and want to raise, you should 37 | raise mutation.ValidationError or some sublcass thereof. Otherwise, it 38 | assumes there were no problems. 39 | 40 | Any function beginning with `validate_` is assumed to be a validator 41 | function and will be run before the mutation can execute. 42 | """ 43 | if not self.email.is_valid(): 44 | raise mutations.ValidationError("email_not_valid", "Email is not valid.") 45 | 46 | def execute(self): 47 | """Executes the mutation. 48 | 49 | This method does the heavy lifting. You can call it by calling .run() on 50 | your mutation class. 51 | """ 52 | user = User.objects.create(email=self.email, name=self.full_name) 53 | if self.send_welcome_email: 54 | EmailServer.deliver(recipient = self.email) 55 | return user 56 | ``` 57 | 58 | ## Calling Commands 59 | 60 | ```python 61 | >>> result = UserSignup.run(email=email, full_name="Bob Boblob") 62 | >>> result.success 63 | True 64 | >>> result.return_value 65 | 66 | >>> result.errors 67 | 68 | result = ... 69 | 70 | ``` 71 | 72 | ```python 73 | >>> result = UserSignup.run(email=None) 74 | >>> result.success 75 | False 76 | >>> result.errors 77 | mutations.ErrorDict({ 78 | 'email': ['email_not_valid'] 79 | }) 80 | >>> result.value 81 | None 82 | ``` 83 | 84 | ## Only Run Validations 85 | 86 | ```python 87 | >>> result = UserSignup.validate(email=email, full_name="Bob Boblob") 88 | >>> result.is_valid 89 | True 90 | ``` 91 | 92 | ## Testing 93 | 94 | ```bash 95 | $ make tests 96 | ``` 97 | 98 | When you're ready to do a release, please make sure tests pass across both 2.7 99 | and 3.6 by running tox: 100 | 101 | ```bash 102 | $ tox 103 | ``` 104 | 105 | # Versioning 106 | 107 | This project uses [Semantic Versioning][semver]. 108 | 109 | # Thanks 110 | 111 | Thanks to Cypriss for the excellent Ruby [Mutations Gem][1]. I created this library because I was looking for something similar for Python. 112 | 113 | [1]: https://github.com/cypriss/mutations 114 | [semver]: https://semver.org/ 115 | [pypi-version]: https://img.shields.io/pypi/v/mutations.svg 116 | [pypi]: https://pypi.org/project/mutations/ 117 | -------------------------------------------------------------------------------- /mutations/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import Mutation 2 | from .error import ValidationError 3 | -------------------------------------------------------------------------------- /mutations/core.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple, defaultdict 2 | import six 3 | 4 | from . import fields 5 | from . import error 6 | from .util import wrap 7 | 8 | 9 | Result = namedtuple('Result', ['success', 'return_value', 'errors']) 10 | ValidationResult = namedtuple('ValidationResult', ['is_valid', 'errors']) 11 | 12 | 13 | class MutationBase(type): 14 | def __new__(mcs, name, bases, attrs): 15 | attrs.update({ 16 | 'fields': {}, 17 | 'validators': defaultdict(list), 18 | 'extra_validators': defaultdict(list) 19 | }) 20 | field_list, extra_validator_list = [], [] 21 | 22 | for k, v in attrs.items(): 23 | if isinstance(v, fields.FieldBase): 24 | field_list.append(k) 25 | elif isinstance(k, str) and k.startswith('validate_'): 26 | extra_validator_list.append(k) 27 | 28 | for f in field_list: 29 | field = attrs.pop(f) 30 | attrs['fields'][f] = field 31 | attrs['validators'][f].extend(wrap(field.validators)) 32 | 33 | for v in extra_validator_list: 34 | validator = attrs.pop(v) 35 | attrs['extra_validators'][v].extend(wrap(validator)) 36 | 37 | return super(MutationBase, mcs).__new__(mcs, name, bases, attrs) 38 | 39 | 40 | @six.add_metaclass(MutationBase) 41 | class Mutation(object): 42 | def __init__(self, name, inputs=None): 43 | self.name = name 44 | self.inputs = inputs or {} 45 | 46 | def __repr__(self): 47 | return ''.format(self.name) 48 | 49 | def __getattr__(self, name): 50 | if name in self.fields: 51 | return self._get_input(name) 52 | else: 53 | raise AttributeError 54 | 55 | def _validate(self): 56 | """Run all validations. 57 | 58 | We validate by doing the following: 59 | 60 | 1. Validate base fields. 61 | 2. Validate extra fields. 62 | 63 | If we encounter an error at any point along the way, mark that there has 64 | been at least one error. Then, try and continue validation, but if we 65 | run into another error (that is not a mutations error), terminate the 66 | validation process. This might be caused by whatever caused the initial 67 | validations. 68 | 69 | Returns a tuple: (was_successful, error_dict) 70 | """ 71 | error_dict = error.ErrorDict() 72 | has_errors = False 73 | 74 | for field, validators in self.validators.items(): 75 | value = self._get_input(field) 76 | for validator in validators: 77 | success, err = validator.validate(value) 78 | if not success: 79 | error_dict[field].append(err) 80 | has_errors = True 81 | 82 | for validator_name, funcs in self.extra_validators.items(): 83 | for func in funcs: 84 | try: 85 | func(self) 86 | except error.ValidationError as err: 87 | has_errors = True 88 | error_dict[validator_name].append(err.as_object()) 89 | except Exception as exc: 90 | if has_errors: 91 | return (False, error_dict) 92 | else: 93 | has_errors = True 94 | error_dict[validator_name].append(exc) 95 | 96 | return (not has_errors, error_dict) 97 | 98 | def _get_input(self, field): 99 | if field in self.inputs: 100 | return self.inputs[field] 101 | elif self.fields[field].has_default: 102 | return self.fields[field].default 103 | return 104 | 105 | def execute(self): 106 | raise error.ExecuteNotImplementedError( 107 | "`execute` should be implemented by the subclass.") 108 | 109 | @classmethod 110 | def run(cls, raise_on_error=False, **kwargs): 111 | """Validate the inputs and then calls execute() to run the command. """ 112 | instance = cls(cls.__name__, inputs = kwargs) 113 | is_valid, error_dict = instance._validate() 114 | 115 | if not is_valid: 116 | if raise_on_error: 117 | raise error.MutationFailedValidationError(error_dict) 118 | else: 119 | return Result(success=False, return_value=None, errors=error_dict) 120 | 121 | result = instance.execute() 122 | return Result(success=True, return_value=result, errors=None) 123 | 124 | @classmethod 125 | def validate(cls, raise_on_error=False, **kwargs): 126 | instance = cls(cls.__name__, inputs = kwargs) 127 | is_valid, error_dict = instance._validate() 128 | if not is_valid: 129 | if raise_on_error: 130 | raise error.MutationFailedValidationError(error_dict) 131 | else: 132 | return ValidationResult(is_valid=False, errors=error_dict) 133 | return ValidationResult(is_valid=is_valid, errors=error_dict) 134 | -------------------------------------------------------------------------------- /mutations/error.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | from six.moves import UserDict 3 | 4 | class MutationError(Exception): 5 | pass 6 | 7 | 8 | class ErrorDict(UserDict): 9 | def __init__(self, *args, **kwargs): 10 | self.default_factory = kwargs.pop('default_factory', list) 11 | UserDict.__init__(self, *args, **kwargs) 12 | 13 | def __getitem__(self, k): 14 | if not k in self.data: 15 | self.data[k] = self.default_factory() 16 | return self.data[k] 17 | 18 | def __add__(self, other): 19 | if self.default_factory != other.default_factory: 20 | raise MutationError("Cannot add two ErrorDicts with different default_factories.") 21 | context = {} 22 | context.update(self) 23 | for key, val in other.items(): 24 | if key in context: 25 | context[key] += val 26 | else: 27 | context[key] = val 28 | return ErrorDict(context, default_factory=self.default_factory) 29 | 30 | @property 31 | def is_empty(self): 32 | return not bool(self.data) 33 | 34 | 35 | ErrorBody = namedtuple('ErrorBody', ['err', 'msg']) 36 | 37 | 38 | class ValidationError(MutationError): 39 | def __init__(self, err=None, msg=None, *args, **kwargs): 40 | self.err = err 41 | self.msg = msg 42 | MutationError.__init__(self, *args, **kwargs) 43 | 44 | def __str__(self): 45 | return str(self.msg) 46 | 47 | def as_object(self): 48 | return ErrorBody(err=self.err, msg=self.msg) 49 | 50 | 51 | class MutationFailedValidationError(ValidationError): 52 | def __init__(self, error_dict={}): 53 | self.error_dict = error_dict 54 | 55 | 56 | class ExecuteNotImplementedError(NotImplementedError, MutationError): 57 | pass 58 | -------------------------------------------------------------------------------- /mutations/fields.py: -------------------------------------------------------------------------------- 1 | from . import validators 2 | import decimal 3 | 4 | 5 | class FieldBase(object): 6 | def __init__(self, name=None, **kwargs): 7 | self.name = name 8 | self._add_validators(**kwargs) 9 | 10 | def _add_validators(self, **kwargs): 11 | self.required = kwargs.pop('required', True) 12 | self.blank = kwargs.pop('blank', False) 13 | self.saved = kwargs.pop('saved', False) 14 | self.has_default = 'default' in kwargs 15 | self.default = kwargs.pop('default', None) 16 | self.saved = kwargs.pop('saved', False) 17 | self.instance_of = kwargs.pop('instance_of', None) 18 | 19 | @property 20 | def validators(self): 21 | _ = [] 22 | if self.required: 23 | _.append(validators.RequiredValidator()) 24 | if not self.blank: 25 | _.append(validators.NotBlankValidator()) 26 | if self.saved: 27 | _.append(validators.SavedObjectValidator()) 28 | if self.instance_of: 29 | _.append(validators.InstanceValidator(self.instance_of)) 30 | if self.extra_validators: 31 | _.append(self.extra_validators) 32 | return _ 33 | 34 | @property 35 | def extra_validators(self): 36 | raise NotImplementedError("implement in subclass") 37 | 38 | 39 | def build_field_for(name, type_obj): 40 | """Shortcut to define a field for a primitive. """ 41 | class DynamicField(FieldBase): 42 | @property 43 | def extra_validators(self): 44 | return validators.InstanceValidator(type_obj) 45 | DynamicField.__name__ = name 46 | return DynamicField 47 | 48 | 49 | ObjectField = build_field_for("ObjectField", object) 50 | BooleanField = build_field_for("BooleanField", bool) 51 | CharField = build_field_for("CharField", str) 52 | StringField = build_field_for("StringField", str) 53 | DictField = build_field_for("DictField", dict) 54 | DecimalField = build_field_for("DecimalField", decimal.Decimal) 55 | NumericField = build_field_for("NumericField", (int, float, decimal.Decimal)) 56 | 57 | 58 | class DuckField(FieldBase): 59 | """Use this for duck-typing. """ 60 | def __init__(self, instance_of, *args, **kwargs): 61 | self.instance_of = instance_of 62 | super().__init__(*args, **kwargs) 63 | 64 | @property 65 | def extra_validators(self): 66 | return validators.InstanceValidator(instance_of=self.instance_of) 67 | -------------------------------------------------------------------------------- /mutations/metadata.py: -------------------------------------------------------------------------------- 1 | # All values must be strings and quoted with singlequote ('). 2 | __version__ = '0.4.0' 3 | __author__ = 'Omar Bohsali' 4 | __authoremail__ = 'me@omarish.com' 5 | -------------------------------------------------------------------------------- /mutations/util.py: -------------------------------------------------------------------------------- 1 | def wrap(item): 2 | if not isinstance(item, (list, tuple)): 3 | return [item] 4 | return item 5 | -------------------------------------------------------------------------------- /mutations/validators.py: -------------------------------------------------------------------------------- 1 | from .error import ErrorBody 2 | 3 | 4 | class ValidatorBase(object): 5 | def validate(self, *args, **kwargs): 6 | valid = self.is_valid(*args, **kwargs) 7 | if not valid: 8 | return (False, self.get_error(*args, **kwargs)) 9 | else: 10 | return (True, None) 11 | 12 | def is_valid(self): 13 | raise NotImplementedError() 14 | 15 | def get_error(self, val): 16 | err = self.__class__.__name__ 17 | msg = "%s failed with input %r." % (self.__class__.__name__, val) 18 | return ErrorBody(err=err, msg=msg) 19 | 20 | 21 | class RequiredValidator(ValidatorBase): 22 | def is_valid(self, val): 23 | return val is not None 24 | 25 | 26 | class NotBlankValidator(ValidatorBase): 27 | def is_valid(self, val, strip=False): 28 | if strip: 29 | return val.strip() != '' 30 | else: 31 | return val != '' 32 | 33 | class InstanceValidator(ValidatorBase): 34 | def __init__(self, instance_of, *args, **kwargs): 35 | if not isinstance(instance_of, (list, tuple)): 36 | instance_of = (instance_of,) 37 | self.instance_of = instance_of 38 | super(InstanceValidator, self).__init__(*args, **kwargs) 39 | 40 | def is_valid(self, val): 41 | return isinstance(val, self.instance_of) 42 | 43 | 44 | class CustomValidator(ValidatorBase): 45 | def __init__(self, func, *args, **kwargs): 46 | self.func = func 47 | super().__init__(self, *args, **kwargs) 48 | 49 | def is_valid(self, *args, **kwargs): 50 | return self.func(*args, **kwargs) 51 | 52 | 53 | class YesValidator(ValidatorBase): 54 | def is_valid(self, *args, **kwargs): 55 | return True 56 | 57 | 58 | class SavedObjectValidator(ValidatorBase): 59 | def is_valid(self, obj): 60 | return obj.pk is not None and obj.pk != '' 61 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = tests 3 | addopts = -v -rsx -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | appnope==0.1.0 2 | astroid==1.6.1 3 | attrs==17.4.0 4 | certifi==2018.4.16 5 | chardet==3.0.4 6 | decorator==4.2.1 7 | idna==2.6 8 | ipython-genutils==0.2.0 9 | isort==4.3.0 10 | jedi==0.11.1 11 | lazy-object-proxy==1.3.1 12 | mccabe==0.6.1 13 | parso==0.1.1 14 | pexpect==4.3.1 15 | pickleshare==0.7.4 16 | pkginfo==1.4.2 17 | pluggy==0.6.0 18 | prompt-toolkit==1.0.15 19 | ptyprocess==0.5.2 20 | py==1.5.2 21 | pycodestyle==2.3.1 22 | Pygments==2.2.0 23 | pylint==1.8.2 24 | pytest==3.4.0 25 | requests==2.18.4 26 | requests-toolbelt==0.8.0 27 | simplegeneric==0.8.1 28 | six==1.11.0 29 | tox==3.2.1 30 | tqdm==4.23.4 31 | traitlets==4.3.2 32 | twine==1.11.0 33 | urllib3==1.22 34 | virtualenv==16.0.0 35 | wcwidth==0.1.7 36 | wrapt==1.10.11 37 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import re 2 | from setuptools import setup 3 | from os import path 4 | 5 | project_path = path.abspath(path.dirname(__file__)) 6 | 7 | 8 | meta_file = open(path.join(project_path, "mutations", "metadata.py")).read() 9 | md = dict(re.findall(r"__([a-z]+)__\s*=\s*'([^']+)'", meta_file)) 10 | 11 | 12 | with open(path.join(project_path, 'README.md')) as f: 13 | long_description = f.read() 14 | 15 | setup( 16 | name='mutations', 17 | version=md['version'], 18 | author=md['author'], 19 | author_email=md['authoremail'], 20 | packages=['mutations'], 21 | url="http://github.com/omarish/mutations", 22 | license='MIT', 23 | description='Encapsulate your business logic in command classes.', 24 | long_description=long_description, 25 | long_description_content_type='text/markdown', 26 | keywords=['business logic', 'django', 'fat models', 'thin models', 'input validation', 'commands', 'validation'], 27 | install_requires=['six>=1.11.0'], 28 | ) 29 | -------------------------------------------------------------------------------- /tests/test_basic.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import mutations 4 | from mutations.validators import RequiredValidator, InstanceValidator, NotBlankValidator 5 | from mutations import error, fields, validators 6 | 7 | 8 | _yes = "this worked!" 9 | _email = "user@example.com" 10 | _name = "Bob Boblob" 11 | _band = 'Nickelback' 12 | 13 | 14 | class SimpleMutation(mutations.Mutation): 15 | email = fields.CharField(required=True) 16 | send_welcome_email = fields.BooleanField(required=False, default=False) 17 | 18 | def execute(self): 19 | return "".join([_yes, _email]) 20 | 21 | 22 | class TestBasics(object): 23 | def test_basics(self): 24 | result = SimpleMutation.run(name=_name, email=_email) 25 | assert result is not None 26 | assert result.success 27 | assert result.errors is None 28 | assert result.return_value == "".join([_yes, _email]) 29 | 30 | def test_requires_execute(self): 31 | """Make sure there's an error if you define a mutation without an 32 | execute() method. """ 33 | with pytest.raises(error.ExecuteNotImplementedError): 34 | class MutationWithoutExecute(mutations.Mutation): 35 | pass 36 | MutationWithoutExecute.run(email = _email) 37 | 38 | def test_raise_on_run(self): 39 | with pytest.raises(error.ValidationError): 40 | SimpleMutation.run(raise_on_error=True) 41 | 42 | def test_raise_on_missing(self): 43 | result = SimpleMutation.run() 44 | assert not result.success 45 | assert 'email' in result.errors 46 | 47 | def test_invalid_input(self): 48 | result = SimpleMutation.run(email=1234) 49 | assert not result.success 50 | assert 'email' in result.errors 51 | 52 | def test_validation_only(self): 53 | v = SimpleMutation.validate(send_welcome_email=True, raise_on_error=False) 54 | assert isinstance(v, mutations.core.ValidationResult) 55 | 56 | with pytest.raises(error.MutationFailedValidationError): 57 | v = SimpleMutation.validate( 58 | send_welcome_email=True, raise_on_error=True) 59 | 60 | v = SimpleMutation.validate(email="user@example.com") 61 | assert isinstance(v, mutations.core.ValidationResult) 62 | assert v.is_valid == True 63 | 64 | class SimpleMutationWithDefault(mutations.Mutation): 65 | email = fields.CharField(required=True) 66 | favorite_band = fields.CharField(required=False, default=_band) 67 | 68 | def execute(self): 69 | return self.favorite_band 70 | 71 | class TestDefaults(object): 72 | def test_default_values(self): 73 | result = SimpleMutationWithDefault.run(email=_email) 74 | assert result.success 75 | assert result.return_value == _band 76 | 77 | class TestValidators(): 78 | def test_basic_validators(self): 79 | result = SimpleMutation.run(email = None) 80 | assert result.success is False 81 | assert 'email' in result.errors 82 | 83 | def test_required_validator(self): 84 | _ = RequiredValidator().is_valid 85 | assert not _(None) 86 | assert _(True) 87 | assert _("foo") 88 | 89 | def test_not_blank_validator(self): 90 | _ = NotBlankValidator().is_valid 91 | assert not _('') 92 | assert _('foo') 93 | 94 | def test_instance_validator(self): 95 | _ = InstanceValidator(list).is_valid 96 | assert not _("foo") 97 | assert _([1, 1, 2, 3, 5, 8, 13, 21]) 98 | assert not _((1, 1, 1)) 99 | 100 | def test_instance_validator_type(self): 101 | class MyType(object): 102 | pass 103 | 104 | _ = InstanceValidator(MyType).is_valid 105 | assert _(MyType()) 106 | -------------------------------------------------------------------------------- /tests/test_complex.py: -------------------------------------------------------------------------------- 1 | from mutations import fields, Mutation, ValidationError 2 | 3 | existing_users = [ 4 | 'user@example.com', 5 | 'user2@example.com' 6 | ] 7 | 8 | 9 | class UserSignup(Mutation): 10 | email = fields.CharField() 11 | name = fields.CharField() 12 | 13 | def validate_no_existing_user(self): 14 | """Ensure no user already exists with this email. """ 15 | if self.email in existing_users: 16 | raise ValidationError("email_exists") 17 | 18 | def validate_email(self): 19 | if "aol.com" in self.email: 20 | raise ValidationError("invalid_email") 21 | 22 | def validate_name(self): 23 | parts = self.name.split() 24 | if len(parts) < 2: 25 | raise ValidationError("need_full_name", "Please enter a full name.") 26 | 27 | def foo_function(self): 28 | return True 29 | 30 | def execute(self): 31 | return self.email 32 | 33 | 34 | class TestComplex(object): 35 | def test_validations(self): 36 | data = { 'email': 'user@example.com', 'name': 'Bob' } 37 | result = UserSignup.run(**data) 38 | assert not result.success 39 | err_keys = result.errors.keys() 40 | assert 'validate_no_existing_user' in err_keys 41 | assert 'validate_name' in err_keys 42 | 43 | def test_missing_field(self): 44 | data = { 'email': 'user@example.com' } 45 | result = UserSignup.run(**data) 46 | assert not result.success 47 | err_keys = result.errors.keys() 48 | assert 'name' in err_keys 49 | 50 | def test_helper_functions_do_not_appear(self): 51 | assert not 'foo_function' in UserSignup.extra_validators 52 | -------------------------------------------------------------------------------- /tests/test_error.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from mutations.error import ErrorDict 4 | import mutations 5 | from mutations import fields 6 | 7 | class TestErrorDict(object): 8 | def test_basics_dict(self): 9 | e = ErrorDict() 10 | assert e.is_empty 11 | e['foo'].append('bar') 12 | assert dict(e) == {'foo': ['bar']} 13 | assert not e.is_empty 14 | 15 | def test_addition_simple(self): 16 | a = ErrorDict({'a': ['b']}) 17 | b = ErrorDict({'c': ['d']}) 18 | c = a + b 19 | assert dict(c) == {'a': ['b'], 'c': ['d']} 20 | 21 | def test_addition_deep(self): 22 | a = ErrorDict({'a': ['b']}) 23 | b = ErrorDict({'a': ['c', 'd'], 'c': ['e']}) 24 | c = a + b 25 | assert c['a'] == ['b', 'c', 'd'] 26 | assert c['c'] == ['e'] 27 | assert isinstance(c, ErrorDict) 28 | 29 | 30 | class ErrantMutation(mutations.Mutation): 31 | email = fields.CharField(required=True) 32 | 33 | def execute(self): 34 | temp = self.this_field_does_not_exist 35 | return "123" 36 | 37 | 38 | class TestErrorHandling(object): 39 | def test_raise_on_invalid_attribute(self): 40 | with pytest.raises(AttributeError): 41 | ErrantMutation.run(email="user@example.com") 42 | -------------------------------------------------------------------------------- /tests/test_resilience.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import unittest 3 | import mutations 4 | import math 5 | 6 | 7 | class FooClass(object): 8 | def a(self): 9 | return "a" 10 | 11 | 12 | class CommandForTest(mutations.Mutation): 13 | batch_size = mutations.fields.ObjectField(instance_of=int, required=True, blank=False) 14 | input_object = mutations.fields.ObjectField(instance_of=FooClass, required=True, blank=True) 15 | 16 | def validate_is_square(self): 17 | root = self.batch_size ** (1 / 2) 18 | if not root == int(root): 19 | raise mutations.ValidationError("not_a_square") 20 | 21 | def validate_input_object(self): 22 | if not self.input_object.a() == "b": 23 | raise mutations.ValidationError("incorrect_response") 24 | 25 | 26 | def test_resillience(): 27 | instance = FooClass() 28 | result = CommandForTest.run(batch_size=None, input_object=instance) 29 | assert not result.success 30 | 31 | 32 | def test_missing_object(): 33 | def validate_nested_method(self): 34 | if not self.input_object.b.c() == "d": 35 | raise mutations.ValidationError('invalid_object') 36 | CommandForTest.validate_nested_method = validate_nested_method 37 | 38 | def validate_triple_nested_method(self): 39 | if not self.input_object.b.c.d() == "e": 40 | raise mutations.ValidationError('invalid_object') 41 | CommandForTest.validate_triple_nested_method = validate_triple_nested_method 42 | 43 | result = CommandForTest.run(input_object=None) 44 | assert not result.success 45 | 46 | 47 | def test_raise_returns_errors(): 48 | with pytest.raises(mutations.error.MutationFailedValidationError) as exc: 49 | CommandForTest.run(raise_on_error=True) 50 | err = exc.value 51 | assert isinstance(err, mutations.error.MutationFailedValidationError) 52 | body = exc.value.error_dict 53 | assert 'batch_size' in body.keys() 54 | assert 'input_object' in body.keys() 55 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27,py36 3 | 4 | [testenv] 5 | deps = pytest 6 | commands = 7 | pytest 8 | --------------------------------------------------------------------------------