├── tests ├── __init__.py ├── other_models.py ├── sample_models.py └── fields_test.py ├── astra ├── __init__.py ├── models.py ├── fields.py ├── validators.py ├── base_fields.py └── model.py ├── .gitignore ├── .travis.yml ├── LICENSE ├── setup.py ├── CHANGELOG.txt └── README.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /astra/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '2.0.1' 2 | -------------------------------------------------------------------------------- /astra/models.py: -------------------------------------------------------------------------------- 1 | from astra.model import Model # NOQA 2 | from astra.fields import * # NOQA 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | redis-astra.egg-info 3 | build/ 4 | dist/ 5 | dump.rdb 6 | .vscode/ 7 | .eggs/ 8 | .cache/ 9 | *.egg-info/ 10 | example.py 11 | test.sh 12 | .pytest_cache/ 13 | .noseids 14 | .coverage 15 | _build/ 16 | -------------------------------------------------------------------------------- /tests/other_models.py: -------------------------------------------------------------------------------- 1 | from astra import models 2 | import redis 3 | 4 | 5 | db = redis.StrictRedis(host='127.0.0.1', decode_responses=True) 6 | 7 | 8 | class SiteColorModel(models.Model): 9 | color = models.CharField() 10 | 11 | def get_db(self): 12 | return db 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | matrix: 3 | include: 4 | - python: 2.7 5 | - python: 3.4 6 | - python: 3.5 7 | - python: 3.6 8 | - python: 3.7 9 | dist: xenial 10 | sudo: true 11 | services: 12 | - redis-server 13 | install: 14 | - pip install -e . 15 | - "if [[ $TEST_PEP8 == '1' ]]; then pip install pep8; fi" 16 | script: "if [[ $TEST_PEP8 == '1' ]]; then pep8 --repeat --show-source --exclude=.venv,.tox,dist,docs,build,*.egg .; else python setup.py test; fi" 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Vladimir K Urushev 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. 22 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | from codecs import open 3 | from os import path 4 | import sys 5 | from astra import __version__ 6 | 7 | from setuptools.command.test import test as TestCommand 8 | 9 | here = path.abspath(path.dirname(__file__)) 10 | 11 | 12 | class PyTest(TestCommand): 13 | def finalize_options(self): 14 | TestCommand.finalize_options(self) 15 | self.test_args = [] 16 | self.test_suite = True 17 | 18 | def run_tests(self): 19 | # import here, because outside the eggs aren't loaded 20 | import pytest 21 | errno = pytest.main(self.test_args) 22 | sys.exit(errno) 23 | 24 | 25 | # Get the long description from the README file 26 | with open(path.join(here, 'README.rst'), encoding='utf-8') as f: 27 | long_description = f.read() 28 | 29 | setup( 30 | name='redis-astra', 31 | version=__version__, 32 | description='ORM for Redis', 33 | long_description=long_description, 34 | url='https://github.com/pilat/redis-astra', 35 | download_url='https://github.com/pilat/redis-astra/tarball/{0}' 36 | .format(__version__), 37 | author='Vladimir K Urushev', 38 | author_email='urushev@yandex.ru', 39 | maintainer='Vladimir K Urushev', 40 | maintainer_email='urushev@yandex.ru', 41 | keywords=['Redis', 'ORM'], 42 | license='MIT', 43 | classifiers=[ 44 | 'Development Status :: 3 - Alpha', 45 | 'Intended Audience :: Developers', 46 | 'License :: OSI Approved :: MIT License', 47 | 'Programming Language :: Python', 48 | 'Programming Language :: Python :: 2.6', 49 | 'Programming Language :: Python :: 2.7', 50 | 'Programming Language :: Python :: 3', 51 | 'Programming Language :: Python :: 3.3', 52 | 'Programming Language :: Python :: 3.4', 53 | 'Programming Language :: Python :: 3.5', 54 | ], 55 | packages=find_packages(exclude=['contrib', 'docs', 'tests']), 56 | install_requires=['redis>=2.9.1', 'six>=1.10.0'], 57 | extras_require={ 58 | 'dev': ['check-manifest'], 59 | 'test': ['coverage', 'mock'], 60 | }, 61 | tests_require=['pytest>=2.5.0'], 62 | cmdclass={'test': PyTest}, 63 | ) 64 | -------------------------------------------------------------------------------- /tests/sample_models.py: -------------------------------------------------------------------------------- 1 | import redis 2 | import uuid 3 | 4 | from astra import models 5 | import datetime as dt 6 | from six import PY2 7 | 8 | 9 | if PY2: 10 | # Autoload only for Python 3 (see TestAutoImport for more info) 11 | from .other_models import SiteColorModel 12 | 13 | 14 | db = redis.StrictRedis(host='127.0.0.1', decode_responses=True) 15 | 16 | 17 | def site_name_validator(value): 18 | if len(value) > 32: 19 | raise ValueError('Site name must be less 32 characters') 20 | 21 | 22 | class SiteObject(models.Model): 23 | name = models.CharHash(validators=[site_name_validator]) 24 | tags = models.Set() # Just a text set 25 | some_child = models.ForeignKey(to='tests.sample_models.ChildExample') 26 | site_color = models.ForeignKey(to='tests.other_models.SiteColorModel', 27 | defaultPk=2) 28 | 29 | def get_db(self): 30 | return db 31 | 32 | 33 | class UserObject(models.Model): 34 | status_choice = ( 35 | 'REGISTERED', 36 | 'ACTIVATED', 37 | 'BANNED', 38 | ) 39 | 40 | name = models.CharHash() 41 | login = models.CharHash() 42 | rating = models.IntegerHash() 43 | paid = models.BooleanHash() 44 | registration_date = models.DateHash() 45 | last_login = models.DateTimeHash() 46 | status = models.EnumHash(enum=status_choice, default='REGISTERED') 47 | inviter = models.ForeignKey(to='tests.sample_models.UserObject') 48 | site1 = models.ForeignKey(to=SiteObject) 49 | site2 = models.ForeignKey(to='tests.sample_models.SiteObject', defaultPk=0) 50 | 51 | credits_test = models.IntegerField() 52 | is_admin = models.BooleanField() 53 | 54 | sites_list = models.List(to='tests.sample_models.SiteObject') 55 | sites_set = models.Set(to='tests.sample_models.SiteObject') 56 | sites_sorted_set = models.SortedSet(to='tests.sample_models.SiteObject') 57 | 58 | def get_db(self): 59 | return db 60 | 61 | 62 | class ParentExample(models.Model): 63 | _ts = models.DateTimeHash() # Creation time 64 | parent_field = models.CharHash() 65 | 66 | def get_db(self): 67 | return db 68 | 69 | def __init__(self, pk=None, **kwargs): 70 | """ Custom designer can act as a PK generator """ 71 | if not pk: 72 | super(ParentExample, self).__init__(uuid.uuid4().hex, **kwargs) 73 | self._ts = dt.datetime.now() 74 | elif pk: 75 | super(ParentExample, self).__init__(pk, **kwargs) 76 | 77 | @property 78 | def exist(self): 79 | return True if self._ts else False 80 | 81 | 82 | class ChildExample(ParentExample): 83 | field1 = models.CharHash() 84 | field2 = models.CharHash() 85 | -------------------------------------------------------------------------------- /CHANGELOG.txt: -------------------------------------------------------------------------------- 1 | v2.0.3 - 2019-01-11 - beta 2 | ================= 3 | 4 | - zpopmin, zpopmax, bzpopmin, bzpopmax are support 5 | 6 | 7 | v2.0.2 - 2018-12-27 - beta 8 | ================= 9 | 10 | - redis-py >3.0.0 is supported 11 | - Detailed error description for inappropriate type of field was revert 12 | 13 | 14 | v2.0.1 - 2018-12-20 - beta 15 | ================= 16 | 17 | - You can override prefixes for model's keys (get_key_prefix method) 18 | - ForeignKey became ForeignField, new ForeignHash was added 19 | - Helpers feature: you can pass datetime value as argument 20 | 21 | 22 | v2.0.1 - 2018-12-17 - beta 23 | ================= 24 | 25 | - Remove all hooks and save() method 26 | - New approach to tracking data changes: setattr, getattr, set_xx, get_xx 27 | - Helpers, getters and setters are being created as precompiled objects 28 | 29 | 30 | v2.0.0 - 2018-12-13 - beta 31 | ================= 32 | 33 | - Fix CharField and CharHash: only strings are accept 34 | - Fix IntegerField and IntegerHash: only numbers are accept 35 | - validator feature for fields 36 | - post_init hook was removed 37 | - pre_assign and post_assign behavior were changed 38 | 39 | 40 | v2.0.0 - 2018-03-27 - beta 41 | ================= 42 | 43 | - Remove signals support. Support "save" method in model instead (See example 44 | in README) 45 | - Remove "database" field in model. Use method get_db() for return 46 | redis.StrictRedis instance 47 | - Remove "prefix" attribute 48 | - Keys are always convert to string 49 | 50 | 51 | v1.0.7 - 2017-02-08 52 | ================= 53 | 54 | - Auto import foreign models 55 | 56 | 57 | v1.0.4 - 2017-01-29 58 | ================= 59 | 60 | - Allow use '0' or 0 as object's pk 61 | - defaultPk attribute for ForeignKey provide default object for not 62 | assigned foreign object. This feature provide access to deep properties 63 | without risk of catch AttributeError. Eg: o = user.site.owner, where site 64 | and owner is ForeignKey to other model, but site is not set. In simple case 65 | we're catch AttributeError: 'NoneType' object has no attribute 'owner'. But, 66 | when site is ForeignKey(to='Site', defaultPk=0), then site always 67 | present as 'Site' model. At the moment, you need provide your 'Site' 68 | behavior with pk key is 0. 69 | - Performance improvement: models using lazy initialization now 70 | 71 | 72 | v1.0.3 - 2017-01-20 73 | ================= 74 | 75 | - (!) Deprecated type: ForeignKeyHash. Link now must be ForeignKey 76 | - Support "withscores" in zrange, zrangebyscore, zrevrange, zrevrangebyscore 77 | methods. This methods return list of tuples 78 | 79 | 80 | v1.0.2 - 2016-12-06 81 | ================= 82 | 83 | - Minor fixes: support ttl, expire commands for scalar fields 84 | 85 | 86 | v1.0.1 - 2016-09-27 87 | ================= 88 | 89 | - List, Set and SortedSet not necessarily refer to other models 90 | - Integer and Char fields return 0 and '' instead None if not assigned 91 | - Enum must have default value 92 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | |PyPI Version| |Build Status| 2 | 3 | ================== 4 | redis-astra 5 | ================== 6 | 7 | Redis-astra is Python light ORM for Redis. 8 | 9 | *Note*: version 2 has uncomportable changes with version 1. See `CHANGELOG.txt `_ 10 | 11 | 12 | Base Example: 13 | 14 | .. code:: python 15 | 16 | import redis 17 | from astra import models 18 | 19 | db = redis.StrictRedis(host='127.0.0.1', decode_responses=True) 20 | 21 | class SiteObject(models.Model): 22 | def get_db(self): 23 | return db 24 | 25 | name = models.CharHash() 26 | 27 | class UserObject(models.Model): 28 | def get_db(self): 29 | return db 30 | 31 | name = models.CharHash() 32 | login = models.CharHash() 33 | site = models.ForeignKey(to=SiteObject) 34 | sites_list = models.List(to=SiteObject) 35 | viewers = models.IntegerField() 36 | 37 | 38 | So you can use it like this: 39 | 40 | .. code:: python 41 | 42 | >>> user = UserObject(pk=1, name="Mike", viewers=5) 43 | >>> user.login = 'mike@null.com' 44 | >>> user.login 45 | 'mike@null.com' 46 | >>> user.viewers_incr(2) 47 | 7 48 | >>> site = SiteObject(pk=1, name="redis.io") 49 | >>> user.site = site 50 | >>> user.sites_list.lpush(site, site, site) 51 | 3 52 | >>> len(user.sites_list) 53 | 3 54 | >>> user.sites_list[2].name 55 | 'redis.io' 56 | >>> user.site = None 57 | >>> user.remove() 58 | 59 | 60 | 61 | You can override some methods for track data changes. For example: 62 | 63 | .. code:: python 64 | 65 | import redis 66 | from astra import models 67 | 68 | db = redis.StrictRedis(host='127.0.0.1', decode_responses=True) 69 | 70 | class User(models.Model): 71 | def get_db(self): 72 | return db 73 | 74 | name = models.CharHash() 75 | login = models.CharHash() 76 | 77 | def set_name(self, value): 78 | self.setattr('name', '%s_was_changed' % value) 79 | 80 | def set_login(self, value): 81 | if '@' not in value: 82 | raise ValueError('Invalid login') 83 | self.setattr('login', value) 84 | 85 | def setattr(self, field_name, value): 86 | if field_name == 'name': 87 | print('Old name: %s' % self.name) 88 | print('Set new name: %s' % value) 89 | 90 | super().setattr(field_name, value) 91 | 92 | u = User(1, name='Alice', login='new@localhost') 93 | >> Old name: 94 | >> Set new name: Alice_was_changed 95 | u.login 96 | >> 'new@localhost' 97 | u.login = 'newlogin' 98 | >> .. ValueError: Invalid login 99 | u.login = 'newlogin@localhost' 100 | u.name = 'New name' 101 | >> Old name: Alice_was_changed 102 | >> Set new name: New name_was_changed 103 | 104 | 105 | 106 | Install 107 | ================== 108 | 109 | Python versions 2.6, 2.7, 3.3, 3.4, 3.5 are supported 110 | Redis-py versions >= 2.9.1 111 | 112 | .. code:: bash 113 | 114 | pip install redis-astra 115 | 116 | 117 | .. |PyPI Version| image:: https://img.shields.io/pypi/v/redis-astra.png 118 | :target: https://pypi.python.org/pypi/redis-astra 119 | .. |Build Status| image:: https://travis-ci.org/pilat/redis-astra.png 120 | :target: https://travis-ci.org/pilat/redis-astra -------------------------------------------------------------------------------- /astra/fields.py: -------------------------------------------------------------------------------- 1 | from astra import base_fields 2 | from astra import validators 3 | 4 | 5 | class CharField(validators.CharValidatorMixin, base_fields.BaseField): 6 | directly_redis_helpers = ('setex', 'setnx', 'append', 'bitcount', 7 | 'getbit', 'getrange', 'setbit', 'setrange', 8 | 'strlen', 'expire', 'ttl') 9 | 10 | 11 | class BooleanField(validators.BooleanValidatorMixin, base_fields.BaseField): 12 | directly_redis_helpers = ('setex', 'setnx', 'expire', 'ttl',) 13 | 14 | 15 | class IntegerField(validators.IntegerValidatorMixin, base_fields.BaseField): 16 | directly_redis_helpers = ('setex', 'setnx', 'incr', 'incrby', 'decr', 17 | 'decrby', 'getset', 'expire', 'ttl',) 18 | 19 | 20 | class ForeignField(validators.ForeignObjectValidatorMixin, 21 | base_fields.BaseField): 22 | def assign(self, value): 23 | if value is None: # Remove field when None was passed 24 | self.db.delete(self.get_key_name()) 25 | else: 26 | super(ForeignField, self).assign(value) 27 | 28 | def obtain(self): 29 | """ 30 | Convert saved pk to target object 31 | """ 32 | if not self._to: 33 | raise RuntimeError('Relation model is not loaded') 34 | value = super(ForeignField, self).obtain() 35 | return self._to_wrapper(value) 36 | 37 | 38 | class ForeignKey(ForeignField): # legacy alias 39 | pass 40 | 41 | class DateField(validators.DateValidatorMixin, base_fields.BaseField): 42 | directly_redis_helpers = ('setex', 'setnx', 'expire', 'ttl',) 43 | 44 | 45 | class DateTimeField(validators.DateTimeValidatorMixin, base_fields.BaseField): 46 | directly_redis_helpers = ('setex', 'setnx', 'expire', 'ttl',) 47 | 48 | 49 | class EnumField(validators.EnumValidatorMixin, base_fields.BaseField): 50 | pass 51 | 52 | 53 | # Hashes 54 | class CharHash(validators.CharValidatorMixin, base_fields.BaseHash): 55 | pass 56 | 57 | 58 | class BooleanHash(validators.BooleanValidatorMixin, base_fields.BaseHash): 59 | pass 60 | 61 | 62 | class IntegerHash(validators.IntegerValidatorMixin, base_fields.BaseHash): 63 | pass 64 | 65 | 66 | class DateHash(validators.DateValidatorMixin, base_fields.BaseHash): 67 | pass 68 | 69 | 70 | class DateTimeHash(validators.DateTimeValidatorMixin, base_fields.BaseHash): 71 | pass 72 | 73 | 74 | class EnumHash(validators.EnumValidatorMixin, base_fields.BaseHash): 75 | pass 76 | 77 | class ForeignHash(validators.ForeignObjectValidatorMixin, 78 | base_fields.BaseHash): 79 | def assign(self, value): 80 | if value is None: # Remove hash key when None was passed 81 | super(ForeignHash, self).remove() 82 | else: 83 | super(ForeignHash, self).assign(value) 84 | 85 | def obtain(self): 86 | """ 87 | Convert saved pk to target object 88 | """ 89 | if not self._to: 90 | raise RuntimeError('Relation model is not loaded') 91 | value = super(ForeignHash, self).obtain() 92 | return self._to_wrapper(value) 93 | 94 | class List(base_fields.BaseCollection): 95 | """ 96 | : 97 | """ 98 | field_type_name = 'list' 99 | 100 | _allowed_redis_methods = ('lindex', 'linsert', 'llen', 'lpop', 'lpush', 101 | 'lpushx', 'lrange', 'lrem', 'lset', 'ltrim', 102 | 'rpop', 'rpoplpush', 'rpush', 'rpushx',) 103 | _single_object_answered_redis_methods = ('lindex', 'lpop', 'rpop',) 104 | _list_answered_redis_methods = ('lrange',) 105 | 106 | def __len__(self): 107 | return self.llen() 108 | 109 | def __getitem__(self, item): 110 | if isinstance(item, slice): 111 | return self.lrange(item.start, item.stop) 112 | else: 113 | ret = self.lrange(item, item) 114 | return ret[0] if len(ret) == 1 else None 115 | 116 | 117 | class Set(base_fields.BaseCollection): 118 | field_type_name = 'set' 119 | _allowed_redis_methods = ('sadd', 'scard', 'sdiff', 'sdiffstore', 'sinter', 120 | 'sinterstore', 'sismember', 'smembers', 'smove', 121 | 'spop', 'srandmember', 'srem', 'sscan', 'sunion', 122 | 'sunionstore') 123 | _single_object_answered_redis_methods = ('spop',) 124 | _list_answered_redis_methods = ('sdiff', 'sinter', 'smembers', 125 | 'srandmember', 'sscan', 'sunion',) 126 | 127 | def __len__(self): 128 | return self.scard() 129 | 130 | 131 | class SortedSet(base_fields.BaseCollection): 132 | field_type_name = 'zset' 133 | _allowed_redis_methods = ('bzpopmax', 'bzpopmin', 'zadd', 'zcard', 134 | 'zcount', 'zincrby', 'zinterstore', 'zlexcount', 135 | 'zrange', 'zpopmax', 'zpopmin', 'zrangebylex', 136 | 'zrangebyscore', 'zrank', 'zrem', 137 | 'zremrangebylex', 'zremrangebyrank', 138 | 'zremrangebyscore', 'zrevrange', 139 | 'zrevrangebylex', 'zrevrangebyscore', 'zrevrank', 140 | 'zscan', 'zscore', 'zunionstore') 141 | _single_object_answered_redis_methods = () 142 | _list_answered_redis_methods = ('zpopmax', 'zpopmin', 'zrange', 143 | 'zrangebylex', 'zrangebyscore', 144 | 'zrevrange', 'zrevrangebylex', 145 | 'zrevrangebyscore', 'zscan', ) 146 | 147 | def __len__(self): 148 | return self.zcard() 149 | 150 | def __getitem__(self, item): 151 | if isinstance(item, slice): 152 | return self.zrangebyscore(item.start or '-inf', 153 | item.stop or '+inf') 154 | else: 155 | ret = self.zrangebyscore(item, item) 156 | return ret[0] if len(ret) == 1 else None 157 | -------------------------------------------------------------------------------- /astra/validators.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | from six import string_types, integer_types, PY2 3 | 4 | 5 | # Validation rules common between hash and fields 6 | class CharValidatorMixin(object): 7 | def _convert_set(self, value): 8 | if not isinstance(value, string_types): 9 | raise ValueError('String expected, but %s was given for ' 10 | 'field %s ' % (type(value).__name__, self.name)) 11 | return value 12 | 13 | def _convert_get(self, value): 14 | return value or '' 15 | 16 | 17 | class BooleanValidatorMixin(object): 18 | def _convert_set(self, value): 19 | if not isinstance(value, bool): 20 | raise ValueError('Boolean expected, but %s was given for ' 21 | 'field %s ' % (type(value).__name__, self.name)) 22 | return '1' if bool(value) else '0' 23 | 24 | def _convert_get(self, value): 25 | return True if value == '1' else False 26 | 27 | 28 | class IntegerValidatorMixin(object): 29 | def _convert_set(self, value): 30 | if not isinstance(value, integer_types): 31 | raise ValueError('Integer expected, but %s was given for ' 32 | 'field %s ' % (type(value).__name__, self.name)) 33 | return str(value) 34 | 35 | def _convert_get(self, value): 36 | try: 37 | return int(value or 0) 38 | except ValueError: 39 | return None 40 | 41 | 42 | class DateValidatorMixin(object): 43 | """ 44 | We're store only seconds on redis. Using microseconds leads to subtle 45 | errors: 46 | import datetime 47 | datetime.datetime.fromtimestamp(t) 48 | (2016, 3, 3, 12, 20, 30, 2) when t = 1457007630.000002, but 49 | (2016, 3, 3, 12, 20, 30) when t = 1457007630.000001 50 | """ 51 | 52 | def _convert_set(self, value): 53 | if not isinstance(value, (dt.datetime, dt.date,)): 54 | raise ValueError('Datetime or date expected, but %s was given for ' 55 | 'field %s ' % (type(value).__name__, self.name)) 56 | 57 | # return round(value.timestamp()) # without microseconds 58 | return value.strftime('%s') # both class implements it 59 | 60 | def _convert_get(self, value): 61 | try: 62 | value = int(value or 0) 63 | except ValueError: 64 | return None 65 | # TODO: maybe use utcfromtimestamp?. 66 | return dt.date.fromtimestamp(value) 67 | 68 | 69 | class DateTimeValidatorMixin(DateValidatorMixin): 70 | def _convert_get(self, value): 71 | try: 72 | value = int(value or 0) 73 | except ValueError: 74 | return None 75 | # TODO: maybe use utcfromtimestamp?. 76 | return dt.datetime.fromtimestamp(value) 77 | 78 | 79 | class EnumValidatorMixin(object): 80 | def __init__(self, enum=list(), default='', **kwargs): 81 | if 'instance' not in kwargs: 82 | # Instant when user define EnumHash. Definition test 83 | if len(enum) < 1: 84 | raise AttributeError('You\'re must define enum list') 85 | for item in enum: 86 | if not isinstance(item, string_types) or item == '': 87 | raise ValueError('Enum list item must be string') 88 | if default not in enum: 89 | raise ValueError('The default value is not present ' 90 | 'in the enum list') 91 | self._enum = enum 92 | self._enum_default = default 93 | super(EnumValidatorMixin, self).__init__( 94 | enum=enum, default=default, **kwargs) 95 | 96 | def _convert_set(self, value): 97 | if value not in self._enum: 98 | raise ValueError('This value is not enumerate') 99 | return value 100 | 101 | def _convert_get(self, value): 102 | return value if value in self._enum else self._enum_default 103 | 104 | 105 | class ForeignObjectValidatorMixin(object): 106 | def __init__(self, to=None, defaultPk=None, **kwargs): 107 | super(ForeignObjectValidatorMixin, self).__init__( 108 | to=to, defaultPk=defaultPk, **kwargs) 109 | self._defaultPk = defaultPk 110 | 111 | if to is None: 112 | return 113 | 114 | if 'instance' in kwargs: 115 | # Replace _to method to foreign constructor 116 | if isinstance(to, string_types): 117 | import sys 118 | to_path = to.split('.') 119 | object_rel = to_path.pop() 120 | package_rel = '.'.join(to_path) 121 | 122 | if PY2: 123 | import imp as _imp 124 | else: 125 | import _imp 126 | 127 | _imp.acquire_lock() 128 | module1 = __import__('.'.join(to_path)) 129 | _imp.release_lock() 130 | 131 | try: 132 | self._to = getattr(sys.modules[package_rel], object_rel) 133 | except AttributeError: 134 | raise AttributeError('Package "%s" not contain model %s' % 135 | (package_rel, object_rel)) 136 | else: 137 | self._to = to 138 | 139 | def _convert_set(self, value): 140 | from astra import model 141 | if isinstance(value, model.Model): 142 | return value.pk 143 | elif isinstance(value, string_types + integer_types): 144 | return value 145 | 146 | raise ValueError('Model instance, string or integer are expected, ' 147 | 'but %s was given for field %s ' % ( 148 | type(value).__name__, self.name)) 149 | 150 | def _convert_get(self, value): 151 | return value 152 | 153 | def _to(self, key): 154 | # Return string key when for models.ForeignKey not specified "to" 155 | # attribute. e.g. author_id = models.ForeignKey() 156 | return key 157 | 158 | def _to_wrapper(self, key): 159 | if key is None: 160 | if self._defaultPk is not None: 161 | return self._to(self._defaultPk) 162 | else: 163 | return None 164 | 165 | return self._to(key) 166 | -------------------------------------------------------------------------------- /astra/base_fields.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | from astra.validators import ForeignObjectValidatorMixin 3 | 4 | 5 | class ModelField(object): 6 | directly_redis_helpers = () # Direct method helpers 7 | field_type_name = '--' 8 | 9 | def __init__(self, **kwargs): 10 | if 'instance' in kwargs: 11 | self.name = kwargs['name'] 12 | self.model = kwargs['model'] 13 | self.db = kwargs['db'] 14 | self.options = kwargs 15 | 16 | def get_key_name(self, is_hash=False): 17 | """ 18 | Create redis key. Schema: 19 | prefix::object_name::field_type::id::field_name, e.g. 20 | prefix::user::fld::12::login 21 | prefix::user::list::12::sites 22 | prefix::user::zset::12::winners 23 | prefix::user::hash::54 24 | """ 25 | items = [self.model.get_key_prefix(), 26 | self.field_type_name, str(self.model.pk)] 27 | if not is_hash: 28 | items.append(self.name) 29 | return '::'.join(items) 30 | 31 | def assign(self, value): 32 | raise NotImplementedError('Subclasses must implement assign') 33 | 34 | def obtain(self): 35 | raise NotImplementedError('Subclasses must implement obtain') 36 | 37 | def get_helper_func(self, method_name): 38 | if method_name not in self.directly_redis_helpers: 39 | raise AttributeError('Invalid attribute with name "%s"' 40 | % (method_name,)) 41 | original_command = getattr(self.db, method_name) 42 | current_key = self.get_key_name() 43 | 44 | def _method_wrapper(*args, **kwargs): 45 | new_args = [current_key] 46 | for v in args: 47 | new_args.append(v) 48 | return original_command(*new_args, **kwargs) 49 | 50 | return _method_wrapper 51 | 52 | def remove(self): 53 | self.db.delete(self.get_key_name()) 54 | 55 | 56 | # Fields: 57 | class BaseField(ModelField): 58 | field_type_name = 'fld' 59 | 60 | def assign(self, value): 61 | saved_value = self._convert_set(value) 62 | self.db.set(self.get_key_name(), saved_value) 63 | 64 | def obtain(self): 65 | value = self.db.get(self.get_key_name()) 66 | return self._convert_get(value) 67 | 68 | def _convert_set(self, value): 69 | """ Check saved value before send to server """ 70 | raise NotImplementedError('Subclasses must implement _convert_set') 71 | 72 | def _convert_get(self, value): 73 | """ Convert server answer to user type """ 74 | raise NotImplementedError('Subclasses must implement _convert_get') 75 | 76 | 77 | # Hashes 78 | class BaseHash(ModelField): 79 | field_type_name = 'hash' 80 | 81 | def assign(self, value): 82 | saved_value = self._convert_set(value) 83 | self.db.hset(self.get_key_name(True), self.name, saved_value) 84 | if self.model._astra_hash_loaded: 85 | self.model._astra_hash[self.name] = saved_value 86 | self.model._astra_hash_exist = True 87 | 88 | def obtain(self): 89 | self._load_hash() 90 | return self._convert_get(self.model._astra_hash.get(self.name, None)) 91 | 92 | def _load_hash(self): 93 | if self.model._astra_hash_loaded: 94 | return 95 | self.model._astra_hash_loaded = True 96 | self.model._astra_hash = \ 97 | self.db.hgetall( 98 | self.get_key_name(True)) 99 | if not self.model._astra_hash: # None if hash field is not exist 100 | self.model._astra_hash = {} 101 | self.model._astra_hash_exist = False 102 | else: 103 | self.model._astra_hash_exist = True 104 | 105 | def _convert_set(self, value): 106 | """ Check saved value before send to server """ 107 | raise NotImplementedError('Subclasses must implement _convert_set') 108 | 109 | def _convert_get(self, value): 110 | """ Convert server answer to user type """ 111 | raise NotImplementedError('Subclasses must implement _convert_get') 112 | 113 | def remove(self): 114 | self.db.hdel(self.get_key_name(True), self.name) 115 | self.model._astra_hash_exist = None # Need to verify again 116 | 117 | def force_check_hash_exists(self): 118 | self.model._astra_hash_exist = bool(self.db.exists( 119 | self.get_key_name(True))) 120 | 121 | 122 | # Implements for three types of lists 123 | class BaseCollection(ForeignObjectValidatorMixin, ModelField): 124 | field_type_name = '' 125 | _allowed_redis_methods = () 126 | _single_object_answered_redis_methods = () 127 | _list_answered_redis_methods = () 128 | # Other methods will be answered directly 129 | 130 | def obtain(self): 131 | return self # for delegate to __getattr__ on this class 132 | 133 | def assign(self, value): 134 | if value is None: 135 | self.remove() 136 | else: 137 | raise ValueError('Collections fields is not possible ' 138 | 'assign directly') 139 | 140 | def __getattr__(self, item): 141 | if item not in self._allowed_redis_methods: 142 | return super(BaseCollection, self).__getattr__(item) 143 | 144 | original_command = getattr(self.db, item) 145 | current_key = self.get_key_name() 146 | 147 | from astra import model 148 | def modify_arg(value): 149 | # Helper could modify your args 150 | if isinstance(value, model.Model): 151 | return value.pk 152 | elif isinstance(value, (dt.datetime, dt.date,)): 153 | return int(value.strftime('%s')) 154 | elif isinstance(value, dict): 155 | # Scan dict and replace datetime values to timestamp. See .zadd 156 | new_dict = {} 157 | for k, v in value.items(): 158 | new_key = modify_arg(k) 159 | new_dict[new_key] = modify_arg(v) 160 | return new_dict 161 | else: 162 | return value 163 | 164 | def _method_wrapper(*args, **kwargs): 165 | # Scan passed args and convert to pk if passed models 166 | new_args = [current_key] 167 | new_kwargs = dict() 168 | for v in args: 169 | new_args.append(modify_arg(v)) 170 | new_kwargs = modify_arg(kwargs) 171 | 172 | # Call original method on the database 173 | answer = original_command(*new_args, **new_kwargs) 174 | 175 | # Wrap to model 176 | if item in self._single_object_answered_redis_methods: 177 | return None if not answer else self._to(answer) 178 | 179 | if item in self._list_answered_redis_methods: 180 | wrapper_answer = [] 181 | for pk in answer: 182 | if not pk: 183 | wrapper_answer.append(None) 184 | else: 185 | if isinstance(pk, tuple) and len(pk) > 0: 186 | wrapper_answer.append((self._to(pk[0]), pk[1])) 187 | else: 188 | wrapper_answer.append(self._to(pk)) 189 | 190 | return wrapper_answer 191 | return answer # Direct answer 192 | 193 | return _method_wrapper 194 | -------------------------------------------------------------------------------- /astra/model.py: -------------------------------------------------------------------------------- 1 | from astra import base_fields 2 | 3 | 4 | class Model(object): 5 | """ 6 | Parent class for all user-defined objects. 7 | For example: 8 | 9 | db = redis.StrictRedis(host='127.0.0.1', decode_responses=True) 10 | 11 | class Stream(models.Model): 12 | name = models.CharHash() 13 | ... 14 | 15 | def get_db(self): 16 | return db 17 | """ 18 | 19 | def __init__(self, pk=None, **kwargs): 20 | self._astra_hash = {} # Hash-object cache 21 | self._astra_hash_loaded = False 22 | self._astra_database = None 23 | self._astra_hash_exist = None 24 | 25 | if pk is None: 26 | raise ValueError('You must pass pk for new or existing object') 27 | self.pk = str(pk) 28 | 29 | self._capture_fields() 30 | self._make_methods() 31 | 32 | # Load fields: 33 | for k in kwargs: 34 | setattr(self, k, kwargs.get(k)) 35 | 36 | def _capture_fields(self): 37 | # Save original fields because they will be replaced to properties 38 | cls = self.__class__ 39 | if not hasattr(cls, '_astra_fields'): 40 | astra_fields = {} 41 | 42 | for k in dir(cls): # vars() ignores parent variables 43 | v = getattr(self.__class__, k) 44 | if isinstance(v, base_fields.ModelField): 45 | astra_fields[k] = v 46 | 47 | setattr(cls, '_astra_fields', astra_fields) 48 | 49 | def _make_methods(self): 50 | # Replace fields to properties, make setters and getters 51 | cls = self.__class__ 52 | if hasattr(cls, '_astra_precompiled'): 53 | return 54 | 55 | astra_fields = getattr(self.__class__, '_astra_fields') 56 | src_lines = [] 57 | for fld in astra_fields.keys(): 58 | field = astra_fields[fld] 59 | 60 | has_implement_get = hasattr(cls, 'get_%s' % fld) 61 | has_implement_set = hasattr(cls, 'set_%s' % fld) 62 | has_implement_del = hasattr(cls, 'del_%s' % fld) 63 | getter_name = 'get_%s' % fld 64 | setter_name = 'set_%s' % fld 65 | deleter_name = 'del_%s' % fld 66 | 67 | # o.field = 123 will call: 68 | # setter for field -> set_field(123) -> setattr('field', 123) 69 | if not has_implement_get: 70 | src_lines.append('def get_%s(self):' % fld) 71 | src_lines.append(' return self.getattr("%s")' % fld) 72 | if not has_implement_set: 73 | src_lines.append('def set_%s(self, value):' % fld) 74 | src_lines.append(' return self.setattr("%s", value)' % fld) 75 | if not has_implement_del: 76 | src_lines.append('def del_%s(self):' % fld) 77 | src_lines.append(' return self.setattr("%s", None)' % fld) 78 | 79 | if has_implement_get: 80 | getter_name = 'getattr(cls, "get_%s")' % fld 81 | if has_implement_set: 82 | setter_name = 'getattr(cls, "set_%s")' % fld 83 | if has_implement_del: 84 | setter_name = 'getattr(cls, "del_%s")' % fld 85 | src_lines.append('%s = property(%s, %s, %s, "%s Property")' % ( 86 | fld, getter_name, setter_name, deleter_name, 87 | fld,)) 88 | 89 | # Helpers code 90 | for helper_name in field.directly_redis_helpers: 91 | src_lines.append('def %s_%s(self, *args, **kwargs):' % ( 92 | fld, helper_name)) 93 | src_lines.append(' return self.apply("%s", "%s", ' 94 | '*args, **kwargs)' % (fld, 95 | helper_name)) 96 | 97 | src_code = '\n'.join(src_lines) 98 | global_scope = {'cls': cls} 99 | local_scope = {} 100 | exec(compile(src_code, '', 'exec'), global_scope, local_scope) 101 | for kkk in local_scope: 102 | setattr(cls, kkk, local_scope[kkk]) 103 | setattr(cls, '_astra_precompiled', True) 104 | 105 | def _get_original_field(self, field_name): 106 | field_key = '_astra_field_%s' % field_name 107 | if not hasattr(self, field_key): 108 | # Create instance from original field on demand 109 | astra_fields = getattr(self.__class__, '_astra_fields') 110 | try: 111 | target_field = astra_fields.get(field_name) 112 | except KeyError as e: 113 | raise AttributeError('%s key is not found' % field_name) 114 | new_instance = target_field.__class__(instance=True, model=self, 115 | name=field_name, 116 | db=self._astra_get_db(), 117 | **target_field.options) 118 | setattr(self, field_key, new_instance) 119 | return new_instance 120 | return getattr(self, field_key) 121 | 122 | def _astra_get_db(self): 123 | if not self._astra_database: 124 | self._astra_database = self.get_db() 125 | return self._astra_database 126 | 127 | def __dir__(self): 128 | return [k for k in super(Model, self).__dir__() \ 129 | if not k.startswith('_astra')] 130 | 131 | def __eq__(self, other): 132 | """ 133 | Compare two models 134 | More magic is here: http://www.rafekettler.com/magicmethods.html 135 | """ 136 | if isinstance(other, Model): 137 | return self.pk == other.pk 138 | return super(Model, self).__eq__(other) 139 | 140 | def __repr__(self): 141 | return '' % (self.__class__.__name__, self.pk) 142 | 143 | def __hash__(self): 144 | return hash('astra:%s:pk:%s' % (self.__class__.__name__, self.pk)) 145 | 146 | def get_db(self): 147 | raise NotImplementedError('get_db method not implemented') 148 | 149 | def get_key_prefix(self, ): 150 | return '::'.join(['astra', self.__class__.__name__.lower()]) 151 | 152 | def setattr(self, field_name, value): 153 | field = self._get_original_field(field_name) 154 | field.assign(value) 155 | 156 | if 'validators' in field.options: 157 | for validator in field.options['validators']: 158 | validator(value) 159 | return value 160 | 161 | def getattr(self, field_name): 162 | field = self._get_original_field(field_name) 163 | return field.obtain() 164 | 165 | def apply(self, field_name, helper_name, *args, **kwargs): 166 | field = self._get_original_field(field_name) 167 | f = field.get_helper_func(helper_name) 168 | return f(*args, **kwargs) 169 | 170 | def remove(self): 171 | # Remove all fields and one time delete entire hash 172 | is_hash_deleted = False 173 | 174 | astra_fields = getattr(self.__class__, '_astra_fields') 175 | for field_name in astra_fields.keys(): 176 | field = self._get_original_field(field_name) 177 | if isinstance(field, base_fields.BaseHash): 178 | if not is_hash_deleted: 179 | is_hash_deleted = True 180 | field.db.delete(field.get_key_name(True)) 181 | else: 182 | field.remove() 183 | self._astra_hash_exist = False 184 | 185 | def hash_exist(self): 186 | if self._astra_hash_exist is None: 187 | hash_found = False 188 | astra_fields = getattr(self.__class__, '_astra_fields') 189 | for field_name in astra_fields.keys(): 190 | field = self._get_original_field(field_name) 191 | if isinstance(field, base_fields.BaseHash): 192 | field.force_check_hash_exists() 193 | hash_found = True 194 | break 195 | if not hash_found: 196 | raise AttributeError('This model doesn\'t contain any hash') 197 | return self._astra_hash_exist 198 | -------------------------------------------------------------------------------- /tests/fields_test.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | import pytest 3 | import redis 4 | from six import PY2 5 | from astra import models 6 | 7 | from .sample_models import UserObject, SiteObject, ParentExample, ChildExample 8 | 9 | 10 | REDIS_PY3 = redis.__version__.startswith('3.') # redis-py newest version 11 | 12 | if not PY2: 13 | from unittest.mock import MagicMock, call, patch 14 | 15 | 16 | # Patch redis. Connection method for collect command sequences for this test 17 | def patched_send_command(self, *args): 18 | if 'commands' in globals(): 19 | commands.append(args) 20 | self.send_packed_command(self.pack_command(*args)) 21 | 22 | 23 | redis.Connection.send_command = patched_send_command 24 | 25 | 26 | # Common class for simplify testing 27 | class CommonHelper(object): 28 | def setup(self): 29 | pass 30 | 31 | def _get_db(self): 32 | global db 33 | return db 34 | 35 | def setup_method(self, test_method): 36 | global db, commands 37 | db = redis.StrictRedis(host='127.0.0.1', decode_responses=True) 38 | 39 | UserObject.get_db = self._get_db 40 | SiteObject.get_db = self._get_db 41 | ParentExample.get_db = self._get_db 42 | 43 | db.flushall() 44 | commands = [] 45 | 46 | def teardown_method(self, test_method): 47 | pass 48 | 49 | # Helpers 50 | def assert_commands_count(self, count): 51 | assert len(commands) == count, commands 52 | 53 | def assert_keys_count(self, count): 54 | assert len(db.keys()) == count 55 | 56 | # primitive mock for testing on Python 2 57 | def simple_mock(self, *args, **kwargs): 58 | if not hasattr(self, 'mock_calls'): 59 | self.mock_calls = 0 60 | self.mock_calls += 1 61 | 62 | 63 | # Test of base fields behavior 64 | class TestModelField(CommonHelper): 65 | def test_primary_key_is_always_string(self): 66 | user = UserObject(1) 67 | user.name = 'Username' 68 | user2 = UserObject('1') 69 | assert user2.name == 'Username' 70 | 71 | def test_set_and_read_model_attrs(self): 72 | user1 = UserObject(1) 73 | user1.name = 'Username' 74 | assert user1.name == 'Username' 75 | user1.name = '12345' 76 | assert user1.name == '12345' 77 | self.assert_commands_count(3) 78 | 79 | def test_remove_object(self): 80 | user1 = UserObject(1) 81 | user1.name = 'Username' 82 | user1.remove() 83 | self.assert_keys_count(0) 84 | 85 | def test_set_attrs_via_kwargs(self): 86 | user1 = UserObject(1, name='Username') 87 | assert user1.name == 'Username' 88 | 89 | user2 = UserObject(name='Username', pk=2) 90 | assert user2.name == 'Username' 91 | 92 | def test_default_values(self): 93 | user1 = UserObject(1) 94 | assert isinstance(user1.name, str) 95 | assert user1.name == '' 96 | assert isinstance(user1.is_admin, bool) 97 | assert user1.is_admin is False 98 | assert isinstance(user1.rating, int) 99 | assert user1.rating == 0 100 | 101 | def test_pk_already_string(self): 102 | user1 = UserObject(1) 103 | assert isinstance(user1.pk, str) 104 | user1 = UserObject('2') 105 | assert isinstance(user1.pk, str) 106 | 107 | @pytest.mark.skipif(PY2, reason="requires python3") 108 | def test_redis_pass_arg_directly(self): 109 | db.hset = MagicMock() 110 | user1 = UserObject(1) 111 | user1.login = '1234' 112 | # redis independently convert key to string 113 | db.hset.assert_called_once_with('astra::userobject::hash::1', 114 | 'login', '1234') 115 | 116 | def test_success_saved(self): 117 | user_write = UserObject(1) 118 | user_write.name = 'Username' 119 | user_write.login = 'user@null.com' 120 | 121 | user_read = UserObject(1) 122 | assert user_read.name == 'Username' 123 | assert user_read.login == 'user@null.com' 124 | self.assert_commands_count(3) 125 | 126 | @pytest.mark.skipif(PY2, reason="requires python3") 127 | def test_read_real_value(self): 128 | db.hgetall = MagicMock(return_value={'name': 'Username'}) 129 | user1 = UserObject(1) 130 | assert user1.name == 'Username' 131 | assert user1.login == '' 132 | 133 | def test_value_after_save(self): 134 | user_write = UserObject(1) 135 | user_write.login = 'user@null.com' 136 | assert user_write.login == 'user@null.com' 137 | self.assert_commands_count(2) # We're can improve it? 138 | 139 | def test_check_prop_after_double_save(self): 140 | user_write = UserObject(1) 141 | user_write.name = 'Username' 142 | user_write.login = 'user@null.com' 143 | user_write.name = 'Username' 144 | assert user_write.name == 'Username' 145 | 146 | user_read = UserObject(1) 147 | assert user_read.name == 'Username' 148 | 149 | def test_with_using_helper(self): 150 | user1 = UserObject(1) 151 | user1.credits_test_setex(10, 214) # value: 214, 10 second ttl 152 | assert user1.credits_test == 214 153 | 154 | def test_hash_exists1(self): 155 | user1 = UserObject(1, name='Test') 156 | assert user1.hash_exist() is True 157 | 158 | def test_hash_exists2(self): 159 | user1 = UserObject(1, name='Test') 160 | read_user1 = UserObject(1) 161 | assert read_user1.hash_exist() is True 162 | 163 | def test_hash_non_exists(self): 164 | user2 = UserObject(2) 165 | assert user2.hash_exist() is False 166 | 167 | def test_model_without_hash(self): 168 | class SampleObject(models.Model): 169 | name = models.CharField() 170 | 171 | def get_db(self): 172 | return db 173 | test_object = SampleObject(1, name='Alice') 174 | with pytest.raises(AttributeError): 175 | a = test_object.hash_exist() 176 | 177 | def test_hash_exists_with_foreign_key(self): 178 | class SampleObject(models.Model): 179 | name = models.CharHash() 180 | site1 = models.ForeignHash(to=SiteObject) 181 | def get_db(self): 182 | return db 183 | site = SiteObject(1, name='Test site') 184 | 185 | o1 = SampleObject(1) 186 | assert not o1.hash_exist() 187 | 188 | o2 = SampleObject(2, name='Test object', site1=site) 189 | assert o2.hash_exist() 190 | o2.site1 = None 191 | assert o2.hash_exist() # e.g. name still exists 192 | 193 | o3 = SampleObject(3, site1=site) 194 | assert o3.hash_exist() 195 | o3.site1 = None 196 | assert not o3.hash_exist() 197 | 198 | def test_alt_keys_prefixes(self): 199 | class SampleObject(models.Model): 200 | name = models.CharField() 201 | def get_db(self): 202 | return db 203 | def get_key_prefix(self): 204 | return 'custom::tst_%s_tst' % self.__class__.__name__.lower() 205 | 206 | SampleObject(1, name='Alice') 207 | assert len(commands) == 1 208 | assert commands[0][1] == 'custom::tst_sampleobject_tst::fld::1::name' 209 | 210 | 211 | # Test hash fields with value conversions: 212 | class TestBaseHash(CommonHelper): 213 | def test_none_to_char_exception(self): 214 | user1 = UserObject(1) 215 | with pytest.raises(ValueError): 216 | user1.login = None 217 | self.assert_commands_count(0) 218 | 219 | def test_false_to_char_exception(self): 220 | user1 = UserObject(1) 221 | with pytest.raises(ValueError): 222 | user1.login = False 223 | self.assert_commands_count(0) 224 | 225 | def test_number_to_char_exception(self): 226 | user1 = UserObject(1) 227 | with pytest.raises(ValueError): 228 | user1.login = 12345 229 | self.assert_commands_count(0) 230 | 231 | 232 | class TestIntegerHash(CommonHelper): 233 | def test_none_to_int_exception(self): 234 | user1 = UserObject(1) 235 | with pytest.raises(ValueError): 236 | user1.rating = None 237 | 238 | def test_char_to_int_exception(self): 239 | user1 = UserObject(1) 240 | with pytest.raises(ValueError): 241 | user1.rating = '23' 242 | 243 | def test_float_to_int_exception(self): 244 | user1 = UserObject(1) 245 | with pytest.raises(ValueError): 246 | user1.rating = 5.2 247 | self.assert_commands_count(0) 248 | 249 | def test_char_to_int_type_conversion(self): 250 | user1 = UserObject(1) 251 | user1.rating = 5 # convert to str on redis 252 | user1_read = UserObject(1) 253 | assert user1_read.rating == 5 # convert back to user type 254 | 255 | 256 | class TestBooleanHash(CommonHelper): 257 | def test_save_not_boolean_exception(self): 258 | user1 = UserObject(1) 259 | with pytest.raises(ValueError): 260 | user1.paid = 1 261 | 262 | def test_save_boolean(self): 263 | user1 = UserObject(1) 264 | user1.paid = True 265 | user1_read = UserObject(1) 266 | assert user1_read.paid is True 267 | 268 | 269 | class TestDateHash(CommonHelper): 270 | def test_empy_date(self): 271 | user1 = UserObject(1) 272 | assert user1.registration_date is not None 273 | 274 | def test_save_not_date_exception(self): 275 | user1 = UserObject(1) 276 | with pytest.raises(ValueError): 277 | user1.registration_date = '2015-01-01' # invalid 278 | 279 | def test_save_date(self): 280 | my_date = dt.date(2016, 3, 2) 281 | user1 = UserObject(1) 282 | user1.registration_date = my_date 283 | user1_read = UserObject(1) 284 | assert user1_read.registration_date == my_date 285 | 286 | 287 | class TestDateTimeHash(CommonHelper): 288 | def test_save_not_date_exception(self): 289 | user1 = UserObject(1) 290 | with pytest.raises(ValueError): 291 | user1.last_login = 2123214121 292 | 293 | def test_save_datetime(self): 294 | # Don't use now() 295 | # my_date = datetime.utcnow().replace(tzinfo=utc) 296 | my_date = dt.datetime(2016, 3, 3, 12, 20, 30) 297 | user1 = UserObject(1) 298 | user1.last_login = my_date 299 | user1_read = UserObject(1) 300 | assert user1_read.last_login == my_date 301 | 302 | def test_save_date_instead_datetime(self): 303 | my_date = dt.date(2016, 3, 2) 304 | user1 = UserObject(1) 305 | user1.last_login = my_date 306 | user1_read = UserObject(1) 307 | assert type(user1_read) is not type(my_date) # return datetime 308 | assert user1_read.last_login.day == my_date.day 309 | assert user1_read.last_login.month == my_date.month 310 | assert user1_read.last_login.year == my_date.year 311 | 312 | 313 | class TestEnumHash(CommonHelper): 314 | def test_invalid_model_define_exception(self): 315 | with pytest.raises(AttributeError): 316 | class SampleObject1(models.Model): 317 | field = models.EnumHash() 318 | 319 | with pytest.raises(ValueError): 320 | class SampleObject2(models.Model): 321 | field = models.EnumHash(enum=('',), default='') 322 | 323 | with pytest.raises(ValueError): 324 | class SampleObject3(models.Model): 325 | field = models.EnumHash(enum=(123, '43'), default='43') 326 | 327 | def test_get_enum_default_value(self): 328 | user1 = UserObject(1) 329 | assert user1.status == 'REGISTERED' 330 | 331 | def test_save_not_enum_value(self): 332 | user1 = UserObject(1) 333 | with pytest.raises(ValueError): 334 | user1.status = 'INVALID_ENUM' 335 | 336 | def test_save_correct_enum_value(self): 337 | user1 = UserObject(1) 338 | user1.status = UserObject.status_choice[1] # ACTIVATED 339 | user1_read = UserObject(1) 340 | assert user1_read.status == UserObject.status_choice[1] 341 | 342 | 343 | class TestHashDelete(CommonHelper): 344 | def test_deleted_operations_count(self): 345 | class SampleObject1(models.Model): 346 | name = models.CharHash() 347 | rating = models.IntegerHash() 348 | field1 = models.CharField() 349 | 350 | def get_db(self): 351 | return db 352 | test_object = SampleObject1(1, name='Alice', rating=22, field1='test') 353 | self.assert_commands_count(3) # two times set hash + field 354 | test_object.remove() 355 | self.assert_commands_count(5) # one time delete entire hash + field 356 | 357 | 358 | class TestLinkField(CommonHelper): 359 | @pytest.fixture(params=[ 360 | (models.ForeignField, 'tests.sample_models.SiteObject'), 361 | (models.ForeignField, SiteObject), 362 | (models.ForeignHash, 'tests.sample_models.SiteObject'), 363 | (models.ForeignHash, SiteObject), 364 | (models.ForeignKey, 'tests.sample_models.SiteObject'), 365 | (models.ForeignKey, SiteObject), 366 | ]) 367 | def sample_object_cls(self, request): 368 | field, to = request.param 369 | class SampleObject(models.Model): 370 | name = models.CharHash() 371 | site1 = field(to=to) 372 | def get_db(self): 373 | return db 374 | return SampleObject 375 | 376 | def test_empty_foreign_link(self, sample_object_cls): 377 | sample_object_cls(1, name='Test object') 378 | o = sample_object_cls(1) 379 | assert not o.site1 380 | 381 | def test_save_foreign_link_as_object(self, sample_object_cls): 382 | site1 = SiteObject(1, name='redis.io') 383 | sample_object_cls(1, name='Test object', site1=site1) 384 | o = sample_object_cls(1) 385 | assert o.site1.pk == '1' 386 | assert isinstance(o.site1, SiteObject) 387 | 388 | def test_save_foreign_link_as_string(self, sample_object_cls): 389 | site2 = SiteObject(2) 390 | site2.name = 'redis.io' 391 | sample_object_cls(1, name='Test object', site1='2') 392 | o = sample_object_cls(1) 393 | assert o.site1.pk == '2' 394 | assert o.site1.name == 'redis.io' 395 | assert isinstance(o.site1, SiteObject) 396 | 397 | def test_foreign_link_remove(self, sample_object_cls): 398 | site1 = SiteObject(1, name='redis.io') 399 | sample_object_cls(1, name='Test object', site1=site1) 400 | o1 = sample_object_cls(1) 401 | o1.site1 = None 402 | o2 = sample_object_cls(1) 403 | assert o2.site1 is None 404 | 405 | 406 | class TestIntegerField(CommonHelper): 407 | def test_save_not_integer_value(self): 408 | user1 = UserObject(1) 409 | with pytest.raises(ValueError): 410 | user1.credits_test = 'abc1' 411 | 412 | def test_save_valid_value(self): 413 | user1 = UserObject(1) 414 | user1.credits_test = 5 415 | user1_read = UserObject(1) 416 | assert user1_read.credits_test == 5 417 | 418 | def test_incremental_and_decremental(self): 419 | user1 = UserObject(1) 420 | user1.credits_test = 11 421 | 422 | user1_modified = UserObject(1) 423 | assert user1_modified.credits_test == 11 424 | user1_modified.credits_test_incr(1) 425 | assert user1_modified.credits_test == 12 426 | 427 | user1_read = UserObject(1) 428 | assert user1_read.credits_test == 12 429 | 430 | user1_modified = UserObject(1) 431 | user1_modified.credits_test_decr(2) 432 | assert user1_modified.credits_test == 10 433 | 434 | user1_read = UserObject(1) 435 | assert user1_read.credits_test == 10 436 | 437 | 438 | class TestBooleanField(CommonHelper): 439 | def test_save_not_boolean_exception(self): 440 | user1 = UserObject(1) 441 | with pytest.raises(ValueError): 442 | user1.is_admin = 1 443 | 444 | def test_save_boolean(self): 445 | user1 = UserObject(1) 446 | user1.is_admin = True 447 | user1_read = UserObject(1) 448 | assert user1_read.is_admin is True 449 | assert user1_read.is_admin is True 450 | self.assert_commands_count(3) # one set and two get 451 | 452 | def test_save_boolean_and_read_twice(self): 453 | user1 = UserObject(1) 454 | user1.is_admin = False 455 | assert user1.is_admin is False 456 | assert user1.is_admin is False 457 | 458 | 459 | class TestFieldDelete(CommonHelper): 460 | def test_invalidate_when_remove(self): 461 | user1 = UserObject(pk=1, credits_test=20) 462 | instance_user1 = UserObject(1) 463 | user1.remove() 464 | assert user1.credits_test == 0 465 | assert instance_user1.credits_test == 0 466 | 467 | 468 | class TestList(CommonHelper): 469 | def test_append_to_list_left_and_right(self): 470 | site1 = SiteObject(1, name='redis.io') 471 | site2 = SiteObject(2, name='google.com') 472 | site3 = SiteObject(3, name='yahoo.com') 473 | 474 | user1 = UserObject(1) 475 | user1.sites_list.lpush(site1) 476 | user1.sites_list.lpush(site2, site3) 477 | user1.sites_list.lpushx(value=site2) # pass by kwarg 478 | user1.sites_list.rpush(site3) 479 | 480 | user1_read = UserObject(1) 481 | assert user1_read.sites_list.llen() == 5 482 | assert user1_read.sites_list.lindex(2) == SiteObject(2) 483 | 484 | def test_invalid_index(self): 485 | user1 = UserObject(1) 486 | assert user1.sites_list.lindex(100) is None 487 | 488 | def test_invalid_pop(self): 489 | user1 = UserObject(1) 490 | assert user1.sites_list.lpop() is None 491 | assert user1.sites_list.rpop() is None 492 | 493 | def test_empty_range(self): 494 | user1 = UserObject(1) 495 | assert user1.sites_list.lrange('-1', '1000') == [] 496 | 497 | def test_valid_range(self): 498 | sites_list = [] 499 | for i in range(1, 4): 500 | sites_list.append(SiteObject(i)) 501 | assert len(sites_list) == 3 502 | 503 | user1 = UserObject(1) 504 | user1.sites_list.lpush(*sites_list) 505 | 506 | user1_read = UserObject(1) 507 | answer_list = user1_read.sites_list.lrange(0, -1) # All 508 | assert len(answer_list) == 3 509 | assert isinstance(answer_list[0], SiteObject) 510 | for site in sites_list: 511 | assert site in answer_list 512 | 513 | def test_separate_fields(self): 514 | site1 = SiteObject('id1') 515 | site2 = SiteObject('id2') 516 | 517 | user1 = UserObject(1) 518 | user2 = UserObject(2) 519 | user1.sites_list.lpush(site1) 520 | user2.sites_list.lpush(site2) 521 | 522 | user1_read = UserObject(1) 523 | user2_read = UserObject(2) 524 | site1_user1_pop = user1_read.sites_list.lpop() 525 | site1_user2_pop = user2_read.sites_list.lpop() 526 | self.assert_commands_count(4) # 2xPUSH and 2xPOP 527 | 528 | assert site1.pk == site1_user1_pop.pk 529 | assert site2.pk == site1_user2_pop.pk 530 | 531 | def test_get_by_slice(self): 532 | site1 = SiteObject('id2') 533 | site2 = SiteObject('id1') 534 | site3 = SiteObject('id0') 535 | 536 | user1 = UserObject(1) 537 | user1.sites_list.lpush(site1, site2) 538 | user1.sites_list.rpush(site3) 539 | 540 | user1_read = UserObject(1) 541 | slice1 = user1_read.sites_list[1:2] 542 | assert len(slice1) == 2 543 | assert slice1[0] == site1 544 | 545 | def test_erase_list(self): 546 | user1 = UserObject(1, name='User123') 547 | site1 = SiteObject('id1') 548 | user1.sites_list.lpush(site1) 549 | assert len(user1.sites_list) == 1 550 | # user1.sites_list = None 551 | user1.remove() 552 | self.assert_keys_count(0) 553 | 554 | 555 | class TestSimpleSet(CommonHelper): 556 | def test_without_foreign(self): 557 | site = SiteObject(1) 558 | site.tags.sadd('games') 559 | site.tags.sadd('shooter') 560 | assert site.tags.sismember('games') is True 561 | assert site.tags.sismember('shooter') is True 562 | assert site.tags.sismember('business') is False 563 | 564 | def test_convert_to_str(self): 565 | site = SiteObject(1) 566 | site.tags.sadd(123) 567 | assert site.tags.sismember(123) is True 568 | assert site.tags.sismember('123') is True 569 | assert site.tags.sismember(456) is False 570 | 571 | def test_get_items(self): 572 | site = SiteObject(1) 573 | site.tags.sadd('fast') 574 | site.tags.sadd('bootstrap') 575 | ret = site.tags.smembers() 576 | assert isinstance(ret, list) 577 | assert 'fast' in ret 578 | assert 'magenta' not in ret 579 | 580 | 581 | class TestSet(CommonHelper): 582 | def test_len(self): 583 | site1 = SiteObject(1) 584 | site2 = SiteObject(2) 585 | 586 | # Lists contains only one item with same value 587 | user1 = UserObject(1) 588 | user1.sites_set.sadd(site1) 589 | user1.sites_set.sadd(site1) 590 | user1.sites_set.sadd(site2) 591 | 592 | user1_read = UserObject(1) 593 | assert user1_read.sites_set.scard() == 2 594 | 595 | def test_is_member(self): 596 | site1 = SiteObject(1) 597 | site2 = SiteObject(2) 598 | 599 | user1 = UserObject(1) 600 | user1.sites_set.sadd(site1, site2) 601 | 602 | user1_read = UserObject(1) 603 | site3 = SiteObject(3) 604 | assert user1_read.sites_set.sismember(site3) is False 605 | 606 | def test_pop(self): 607 | site1 = SiteObject(1) 608 | site2 = SiteObject(2) 609 | 610 | user1 = UserObject(1) 611 | user1.sites_set.sadd(site1) 612 | user1.sites_set.sadd(site1) 613 | user1.sites_set.sadd(site2) 614 | 615 | user1_read = UserObject(1) 616 | read_site = user1_read.sites_set.spop() 617 | # Remember that the order in sets is not respected: 618 | assert read_site == site2 or read_site == site1 619 | 620 | def test_erase_set(self): 621 | user1 = UserObject(1, name='User123') 622 | site1 = SiteObject('id1') 623 | user1.sites_set.sadd(site1) 624 | assert len(user1.sites_set) == 1 625 | user1.sites_set = None 626 | user1.remove() 627 | self.assert_keys_count(0) 628 | 629 | 630 | class TestSortedSet(CommonHelper): 631 | @pytest.fixture 632 | def user1(self): 633 | site1 = SiteObject(1) 634 | site2 = SiteObject(2) 635 | site3 = SiteObject(3) 636 | user1 = UserObject(1, name='Test User') 637 | 638 | # Add items score, member, score, member, ... 639 | if REDIS_PY3: 640 | user1.sites_sorted_set.zadd({ 641 | site1: 100, 642 | site2: 300, 643 | site1: 200, 644 | site3: 400}) 645 | else: 646 | user1.sites_sorted_set.zadd( 647 | 100, site1, 648 | 300, site2, 649 | 200, site1, 650 | 400, site3) 651 | 652 | return user1 653 | 654 | def test_add_and_zrange(self, user1): 655 | site2 = SiteObject(2) 656 | 657 | answered_list = user1.sites_sorted_set.zrange(1, 1) 658 | assert len(answered_list) == 1 659 | assert answered_list[0] == site2 660 | 661 | def test_add_and_zrange_by_score(self, user1): 662 | site3 = SiteObject(3) 663 | 664 | assert user1.sites_sorted_set.zcard() == 3 # only original objects 665 | answered_list = user1.sites_sorted_set.zrangebyscore(301, 400) 666 | assert len(answered_list) == 1 667 | assert answered_list[0] == site3 668 | 669 | def test_get_with_scores(self): 670 | user1 = UserObject(1) 671 | for i in range(1, 10): 672 | site = SiteObject(i) 673 | if REDIS_PY3: 674 | user1.sites_sorted_set.zadd({site: i*100}) 675 | else: 676 | user1.sites_sorted_set.zadd(i*100, site) 677 | 678 | with_scores = user1.sites_sorted_set.zrangebyscore('-inf', '+inf', 679 | withscores=True) 680 | assert isinstance(with_scores, list) 681 | first_item = with_scores[0] 682 | assert isinstance(first_item, tuple) 683 | assert isinstance(first_item[0], SiteObject) 684 | assert isinstance(first_item[1], float) 685 | 686 | def test_get_by_slice(self, user1): 687 | site2 = SiteObject(2) 688 | site3 = SiteObject(3) 689 | 690 | result_slice = user1.sites_sorted_set[300:400] 691 | assert result_slice[0] == site2 692 | assert result_slice[1] == site3 693 | 694 | def test_rem_by_score(self, user1): 695 | site3 = SiteObject(3) 696 | 697 | user1_modified = UserObject(1) 698 | user1_modified.sites_sorted_set.zremrangebyscore(200, 300) 699 | 700 | # reverse get: 701 | answered_list = user1.sites_sorted_set.zrevrangebyscore('+inf', '-inf') 702 | assert len(answered_list) == 1 703 | assert answered_list[0] == site3 704 | 705 | def test_erase_sorted_set(self, user1): 706 | assert len(user1.sites_sorted_set) == 3 707 | user1.sites_sorted_set = None 708 | user1.remove() 709 | self.assert_keys_count(0) 710 | 711 | def test_helper_could_convert_values(self): 712 | class SampleStorage(models.Model): 713 | items = models.SortedSet(to=ChildExample) 714 | def get_db(self): 715 | return db 716 | 717 | storage = SampleStorage(1) 718 | 719 | child1 = ChildExample() # ts will be added 720 | assert isinstance(child1._ts, dt.datetime) 721 | 722 | if REDIS_PY3: 723 | storage.items.zadd({child1: child1._ts}) 724 | else: 725 | storage.items.zadd(child1._ts, child1) 726 | 727 | assert len(storage.items) == 1 728 | 729 | 730 | class TestInheritance(CommonHelper): 731 | def test_set_and_get(self): 732 | child1 = ChildExample() # Custom constructor generates unique pk 733 | child1.parent_field = 'test0' 734 | child1.field1 = 'test1' 735 | 736 | child1_id = child1.pk 737 | 738 | read_child = ChildExample(child1_id) 739 | assert read_child.parent_field == 'test0' 740 | assert read_child.field1 == 'test1' 741 | 742 | 743 | class TestAttsBase(CommonHelper): 744 | def test_set_prop(self): 745 | user1 = UserObject(1) 746 | user1.name = 'User123' 747 | assert UserObject(1).name == 'User123' 748 | 749 | def test_set_prop_on_init(self): 750 | user1 = UserObject(1, name='User123') 751 | assert UserObject(1).name == 'User123' 752 | 753 | def test_set_prop_by_setter(self): 754 | user1 = UserObject(1) 755 | user1.set_name('User123') 756 | assert UserObject(1).name == 'User123' 757 | 758 | def test_set_prop_by_setattr(self): 759 | user1 = UserObject(1) 760 | user1.setattr('name', 'User123') 761 | assert UserObject(1).name == 'User123' 762 | 763 | def test_get_prop_by_getter(self): 764 | user1 = UserObject(1, name='User123') 765 | assert UserObject(1).get_name() == 'User123' 766 | 767 | def test_get_prop_by_getprop(self): 768 | user1 = UserObject(1, name='User123') 769 | assert UserObject(1).getattr('name') == 'User123' 770 | 771 | 772 | class TestAttrsExtend(CommonHelper): 773 | @pytest.mark.parametrize('field_type', [ 774 | models.CharHash, 775 | models.CharField 776 | ]) 777 | def test_value_on_save(self, field_type): 778 | class SampleObject1(models.Model): 779 | name = field_type() 780 | description = field_type() 781 | 782 | def get_db(self): 783 | return db 784 | 785 | def setattr(self, field_name, value): 786 | if field_name == 'name': 787 | value = '%s_was_changed' % value 788 | return super(SampleObject1, self).setattr(field_name, value) 789 | 790 | test_object = SampleObject1(1) 791 | test_object.name = 'name' 792 | test_object.description = 'description' 793 | assert test_object.name == 'name_was_changed' 794 | assert test_object.description == 'description' 795 | 796 | @pytest.mark.skipif(PY2, reason="requires python3") 797 | def test_track_changes(self): 798 | with patch.object(UserObject, 'setattr', 799 | side_effect=['User1', 5]) as mo: 800 | user1 = UserObject(1, name='User1') 801 | user1.rating = 5 802 | mo.assert_has_calls([ 803 | call('name', 'User1'), 804 | call('rating', 5), 805 | ], any_order=True) 806 | 807 | @pytest.mark.skipif(PY2, reason="requires python3") 808 | def test_m2m_link_signal(self): 809 | site = SiteObject(pk=1, name="redis.io") 810 | with patch.object(UserObject, 'setattr', return_value=None) as mo: 811 | user1 = UserObject(1) 812 | user1.site1 = site 813 | mo.assert_called_with('site1', site) 814 | 815 | @pytest.mark.skipif(PY2, reason="requires python3") 816 | def test_m2m_remove_signal(self): 817 | site = SiteObject(pk=1, name="redis.io") 818 | user1 = UserObject(1) 819 | user1.site1 = site 820 | with patch.object(UserObject, 'setattr', return_value=None) as mo: 821 | user1.site1 = None 822 | mo.assert_called_once_with('site1', None) 823 | 824 | @pytest.mark.skipif(PY2, reason="requires python3") 825 | def test_remove_signals(self): 826 | user1 = UserObject(pk=1, name='Mike', rating=5) 827 | with patch.object(UserObject, 'remove', return_value=None) as mo: 828 | user1.remove() 829 | mo.assert_called_once_with() 830 | 831 | @pytest.mark.skipif(PY2, reason="requires python3") 832 | def test_set_attr_feature(self): 833 | class SampleObject(models.Model): 834 | name = models.CharHash() 835 | def get_db(self): 836 | return db 837 | def set_name(self, value): 838 | self.setattr('name', value + 'AA') 839 | 840 | obj = SampleObject(1) 841 | obj.name = '123' 842 | assert obj.name == '123AA' 843 | 844 | def test_set_attr_feature2(self): 845 | class SampleObject(models.Model): 846 | name = models.CharHash() 847 | def get_db(self): 848 | return db 849 | def get_name(self): 850 | v = self.getattr('name') 851 | return v + 'OO' 852 | 853 | obj = SampleObject(1) 854 | obj.name = '123' 855 | assert obj.name == '123OO' 856 | 857 | @pytest.mark.skipif(PY2, reason="requires python3") 858 | def test_set_attr_feature_calls(self): 859 | class SampleObject(models.Model): 860 | name = models.CharHash() 861 | def get_db(self): 862 | return db 863 | def set_name(self, value): 864 | self.setattr('name', value + 'AA') 865 | 866 | with patch.object(SampleObject, 'set_name', return_value=None) as mo: 867 | obj = SampleObject(2) 868 | obj.name = '123' 869 | mo.assert_called_with(obj, '123') 870 | 871 | def test_set_attr_feature_on_init(self): 872 | class SampleObject(models.Model): 873 | name = models.CharHash() 874 | def get_db(self): 875 | return db 876 | def set_name(self, value): 877 | self.setattr('name', value + 'AA') 878 | 879 | obj = SampleObject(1, name='123') 880 | assert obj.name == '123AA' 881 | 882 | 883 | class TestDeepAttributes(CommonHelper): 884 | def test_access_to_none_attribute(self): 885 | user1 = UserObject(1, name='User1') 886 | assert user1.site1 is None 887 | 888 | def test_exception_to_deep_attribute(self): 889 | user1 = UserObject(1, name='User1') 890 | with pytest.raises(AttributeError): 891 | # 'NoneType' object has no attribute 'some_child' 892 | k = user1.site1.some_child 893 | 894 | def test_deep_attribute_with_default_model(self): 895 | user1 = UserObject(1, name='User1') 896 | assert user1.site2.some_child is None 897 | 898 | 899 | class TestAutoImport(CommonHelper): 900 | """ 901 | Check case which SiteColorModel class is not loaded while SiteObject 902 | initialized 903 | """ 904 | 905 | def test_with_deferred_import(self): 906 | site1 = SiteObject(1, name='example.com') 907 | 908 | from .other_models import SiteColorModel 909 | assert isinstance(site1.site_color, SiteColorModel) 910 | 911 | 912 | class TestValidators(CommonHelper): 913 | def test_validator_feature(self): 914 | site = SiteObject(1, name='x'*32) 915 | with pytest.raises(ValueError): 916 | site.name = 'x'*33 917 | 918 | @pytest.mark.skipif(PY2, reason="requires python3") 919 | @pytest.mark.parametrize('field_type', [ 920 | models.CharHash, 921 | models.CharField 922 | ]) 923 | def test_validators_1(self, field_type): 924 | mock = MagicMock() 925 | class SampleObject1(models.Model): 926 | name = field_type(validators=[mock]) 927 | 928 | def get_db(self): 929 | return db 930 | 931 | test_object = SampleObject1(1) 932 | test_object.name = 'Name' 933 | mock.assert_called_once_with('Name') 934 | 935 | @pytest.mark.skipif(PY2, reason="requires python3") 936 | @pytest.mark.parametrize('field_type', [ 937 | models.IntegerField, 938 | models.IntegerHash 939 | ]) 940 | def test_validators_2(self, field_type): 941 | mock = MagicMock() 942 | class SampleObject1(models.Model): 943 | value = field_type(validators=[mock]) 944 | 945 | def get_db(self): 946 | return db 947 | 948 | test_object = SampleObject1(1, value=1234) 949 | mock.assert_called_once_with(1234) 950 | --------------------------------------------------------------------------------