├── .coveragerc ├── .gitignore ├── .travis.yml ├── AUTHORS ├── CHANGES.md ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── computed_property ├── __init__.py ├── fields.py └── tests │ ├── __init__.py │ ├── models.py │ ├── settings │ ├── __init__.py │ ├── base.py │ ├── mysql_innodb.py │ ├── postgres.py │ ├── sqlite.py │ └── sqlite_file.py │ └── test_fields.py ├── doc ├── Makefile ├── conf.py └── index.rst ├── requirements.txt ├── runtests.py ├── setup.cfg ├── setup.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = computed_property 3 | omit = computed_property/tests/* 4 | branch = 1 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # Distribution / packaging 7 | .Python 8 | env/ 9 | build/ 10 | develop-eggs/ 11 | dist/ 12 | downloads/ 13 | eggs/ 14 | .eggs/ 15 | lib/ 16 | lib64/ 17 | sdist/ 18 | var/ 19 | wheels/ 20 | *.egg-info/ 21 | .installed.cfg 22 | *.egg 23 | 24 | # PyInstaller 25 | # Usually these files are written by a python script from a template 26 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 27 | *.manifest 28 | *.spec 29 | 30 | # Installer logs 31 | pip-log.txt 32 | pip-delete-this-directory.txt 33 | 34 | # Unit test / coverage reports 35 | htmlcov/ 36 | .tox/ 37 | .coverage 38 | .coverage.* 39 | .cache 40 | coverage.xml 41 | *.cover 42 | 43 | # Sphinx documentation 44 | doc/_build/ 45 | 46 | # PyBuilder 47 | target/ 48 | 49 | # Jupyter Notebook 50 | .ipynb_checkpoints 51 | 52 | # pyenv 53 | .python-version 54 | 55 | # PyCharm 56 | .idea 57 | 58 | venv/ 59 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: xenial 2 | language: python 3 | 4 | cache: pip 5 | matrix: 6 | include: 7 | - python: 2.7 8 | env: TOXENV=py27-flake8 9 | - python: 3.7 10 | env: TOXENV=py37-flake8 11 | 12 | - python: 2.7 13 | env: TOXENV=py27-django18-sqlite,py27-django18-sqlite_file,py27-django18-postgres,py27-coverage 14 | - python: 2.7 15 | env: TOXENV=py27-django19-sqlite,py27-django19-sqlite_file,py27-django19-postgres,py27-coverage 16 | - python: 2.7 17 | env: TOXENV=py27-django110-sqlite,py27-django110-sqlite_file,py27-django110-postgres,py27-coverage 18 | - python: 2.7 19 | env: TOXENV=py27-django111-sqlite,py27-django111-sqlite_file,py27-django111-postgres,py27-coverage 20 | 21 | - python: pypy 22 | env: TOXENV=pypy-django18-sqlite,pypy-django18-sqlite_file,pypy-django18-postgres 23 | - python: pypy 24 | env: TOXENV=pypy-django19-sqlite,pypy-django19-sqlite_file,pypy-django19-postgres 25 | - python: pypy 26 | env: TOXENV=pypy-django110-sqlite,pypy-django110-sqlite_file,pypy-django110-postgres 27 | - python: pypy 28 | env: TOXENV=pypy-django111-sqlite,pypy-django111-sqlite_file,pypy-django111-postgres 29 | 30 | - python: 3.4 31 | env: TOXENV=py34-django18-sqlite,py34-django18-sqlite_file,py34-django18-postgres 32 | - python: 3.4 33 | env: TOXENV=py34-django19-sqlite,py34-django19-sqlite_file,py34-django19-postgres 34 | - python: 3.4 35 | env: TOXENV=py34-django110-sqlite,py34-django110-sqlite_file,py34-django110-postgres 36 | - python: 3.4 37 | env: TOXENV=py34-django111-sqlite,py34-django111-sqlite_file,py34-django111-postgres 38 | - python: 3.4 39 | env: TOXENV=py34-django20-sqlite,py34-django20-sqlite_file,py34-django20-postgres 40 | 41 | - python: 3.5 42 | env: TOXENV=py35-django18-sqlite,py35-django18-sqlite_file,py35-django18-postgres 43 | - python: 3.5 44 | env: TOXENV=py35-django19-sqlite,py35-django19-sqlite_file,py35-django19-postgres 45 | - python: 3.5 46 | env: TOXENV=py35-django110-sqlite,py35-django110-sqlite_file,py35-django110-postgres 47 | - python: 3.5 48 | env: TOXENV=py35-django111-sqlite,py35-django111-sqlite_file,py35-django111-postgres 49 | - python: 3.5 50 | env: TOXENV=py35-django20-sqlite,py35-django20-sqlite_file,py35-django20-postgres 51 | - python: 3.5 52 | env: TOXENV=py35-django21-sqlite,py35-django21-sqlite_file,py35-django21-postgres 53 | - python: 3.5 54 | env: TOXENV=py35-django22-sqlite,py35-django22-sqlite_file,py35-django22-postgres 55 | 56 | - python: 3.6 57 | env: TOXENV=py36-django111-sqlite,py36-django111-sqlite_file,py36-django111-postgres 58 | - python: 3.6 59 | env: TOXENV=py36-django20-sqlite,py36-django20-sqlite_file,py36-django20-postgres 60 | - python: 3.6 61 | env: TOXENV=py36-django21-sqlite,py36-django21-sqlite_file,py36-django21-postgres 62 | - python: 3.6 63 | env: TOXENV=py36-django22-sqlite,py36-django22-sqlite_file,py36-django22-postgres 64 | - python: 3.6 65 | env: TOXENV=py36-djangotrunk-sqlite,py36-django22-sqlite_file,py36-django22-postgres 66 | 67 | - python: 3.7 68 | env: TOXENV=py37-django111-sqlite,py37-django111-sqlite_file,py37-django111-postgres,py37-coverage 69 | - python: 3.7 70 | env: TOXENV=py37-django20-sqlite,py37-django20-sqlite_file,py37-django20-postgres,py37-coverage 71 | - python: 3.7 72 | env: TOXENV=py37-django21-sqlite,py37-django21-sqlite_file,py37-django21-postgres,py37-coverage 73 | - python: 3.7 74 | env: TOXENV=py37-django22-sqlite,py37-django22-sqlite_file,py37-django22-postgres,py37-coverage 75 | - python: 3.7 76 | env: TOXENV=py37-djangotrunk-sqlite,py37-django22-sqlite_file,py37-django22-postgres,py37-coverage 77 | 78 | - python: pypy3 79 | env: TOXENV=pypy3-django111-sqlite,pypy3-django111-sqlite_file,pypy3-django111-postgres 80 | - python: pypy3 81 | env: TOXENV=pypy3-django20-sqlite,pypy3-django20-sqlite_file,pypy3-django20-postgres 82 | - python: pypy3 83 | env: TOXENV=pypy3-django21-sqlite,pypy3-django21-sqlite_file,pypy3-django21-postgres 84 | - python: pypy3 85 | env: TOXENV=pypy3-django22-sqlite,pypy3-django22-sqlite_file,pypy3-django22-postgres 86 | - python: pypy3 87 | env: TOXENV=pypy3-djangotrunk-sqlite,pypy3-django22-sqlite_file,pypy3-django22-postgres 88 | 89 | - python: 3.7 90 | env: TOXENV=py37-docs 91 | 92 | 93 | services: 94 | - postgresql 95 | 96 | install: travis_retry pip install coveralls tox-travis 97 | 98 | before_script: 99 | - psql -c 'create database travis_ci_test;' -U postgres 100 | 101 | script: tox 102 | 103 | after_success: 104 | - coveralls -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | # This is the list of contributors to the library 2 | # 3 | # Please add your name and email, or organization if applicable. 4 | 5 | Jason Brechin 6 | Clément Verna fedora-infra 7 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ### latest 4 | 5 | ### 0.3 [2019-07-28] 6 | - Update six dependency to >= 1.11 7 | - Refactor Travis/tox configuration 8 | 9 | ### 0.2.4 [2019-05-09] 10 | - Add additional Python and Django versions to tests 11 | - Add Postgres to tested database backends 12 | - Update docs with note on usage 13 | 14 | ### 0.2.3 [2018-11-29] 15 | - Add Python 3.7 testing 16 | 17 | ### 0.2.2 [2018-09-21] 18 | - Drop Python 3.3 testing/support 19 | 20 | ### 0.2.1 [2018-01-29] 21 | - Add other field types 22 | 23 | ### 0.2 [2018-01-28] 24 | - Use field's `to_python` to coerce value on access/save 25 | 26 | ### 0.1 [2018-01-27] 27 | - Initial release -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | Thank you for considering making a contribution to the development of this library! 4 | 5 | 6 | ## Reporting Issues or Suggestions 7 | If you see an issue with the library or have a request for new functionality, please file an issue in GitHub. 8 | Please include specifics about which version of Python and Django you are using, 9 | as well as which version of the django-computed-property library you are using. 10 | Whenever possible, include a code sample that demonstrates the issue you are seeing or the desired developer experience. 11 | 12 | ## Making a contribution 13 | Submit a pull request with your changes. Ensure that you have added tests to 14 | cover any modifications you have made. Add yourself to the `AUTHORS` file, 15 | add a summary of changes to the `CHANGES.md`, and update documentation if necessary. 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Jason Brechin 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | coverage erase 3 | tox 4 | coverage html -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Coverage Status](https://coveralls.io/repos/github/brechin/django-computed-property/badge.svg?branch=master)](https://coveralls.io/github/brechin/django-computed-property?branch=master) 2 | [![Build Status](https://travis-ci.org/brechin/django-computed-property.svg?branch=master)](https://travis-ci.org/brechin/django-computed-property) 3 | # django-computed-property 4 | Computed Property Fields for Django 5 | 6 | Inspired by [Google Cloud Datastore NDB Computed Properties](https://cloud.google.com/appengine/docs/standard/python/ndb/entity-property-reference#computed). 7 | 8 | Automatically store a computed value in the database when saving your model so you can filter 9 | on values requiring complex (or simple!) calculations. 10 | 11 | ## Quick start 12 | 13 | 1. Install this library 14 | 15 | ``` 16 | pip install django-computed-property 17 | ``` 18 | 19 | 1. Add to `INSTALLED_APPS` 20 | 21 | ```python 22 | INSTALLED_APPS = [ 23 | ..., 24 | 'computed_property' 25 | ] 26 | ``` 27 | 28 | 1. Add a computed field to your model(s) 29 | 30 | ```python 31 | from django.db import models 32 | import computed_property 33 | 34 | class MyModel(models.Model): 35 | doubled = computed_property.ComputedIntegerField(compute_from='double_it') 36 | base = models.IntegerField() 37 | 38 | def double_it(self): 39 | return self.base * 2 40 | ``` 41 | 42 | Documentation available at http://django-computed-property.readthedocs.io/en/latest/ 43 | -------------------------------------------------------------------------------- /computed_property/__init__.py: -------------------------------------------------------------------------------- 1 | from .fields import * # noqa 2 | 3 | __version__ = '0.3.0' 4 | -------------------------------------------------------------------------------- /computed_property/fields.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | from django.core.exceptions import ImproperlyConfigured 4 | from django.db import models 5 | from django.db.models.signals import pre_save 6 | 7 | __all__ = [ 8 | 'ComputedBooleanField', 9 | 'ComputedCharField', 10 | 'ComputedDateField', 11 | 'ComputedDateTimeField', 12 | 'ComputedDecimalField', 13 | 'ComputedEmailField', 14 | 'ComputedField', 15 | 'ComputedFloatField', 16 | 'ComputedIntegerField', 17 | 'ComputedPositiveIntegerField', 18 | 'ComputedPositiveSmallIntegerField', 19 | 'ComputedSmallIntegerField', 20 | 'ComputedTextField', 21 | 'ComputedTimeField', 22 | ] 23 | 24 | 25 | class ComputedField(models.Field): 26 | def __init__(self, compute_from=None, *args, **kwargs): 27 | kwargs['editable'] = False 28 | if compute_from is None: 29 | raise ImproperlyConfigured( 30 | '%s requires setting compute_from' % 31 | self.__class__.__name__ 32 | ) 33 | super(ComputedField, self).__init__(*args, **kwargs) 34 | self.compute_from = compute_from 35 | 36 | class ObjectProxy(object): 37 | def __init__(self, field): 38 | self.field = field 39 | 40 | def __get__(self, instance, cls=None): 41 | if instance is None: 42 | return self 43 | value = self.field.calculate_value(instance) 44 | instance.__dict__[self.field.name] = value 45 | return value 46 | 47 | def __set__(self, obj, value): 48 | pass 49 | 50 | def contribute_to_class(self, cls, name, **kwargs): 51 | """Add field to class using ObjectProxy so that 52 | calculate_value can access the model instance.""" 53 | self.set_attributes_from_name(name) 54 | cls._meta.add_field(self) 55 | self.model = cls 56 | setattr(cls, name, ComputedField.ObjectProxy(self)) 57 | pre_save.connect(self.resolve_computed_field, sender=cls) 58 | 59 | def resolve_computed_field(self, sender, instance, raw, **kwargs): 60 | """Pre-save signal receiver to compute new field value.""" 61 | setattr(instance, self.get_attname(), self.calculate_value(instance)) 62 | return self.calculate_value(instance) 63 | 64 | def calculate_value(self, instance): 65 | """ 66 | Retrieve or call function to obtain value for this field. 67 | Args: 68 | instance: Parent model instance to reference 69 | """ 70 | if callable(self.compute_from): 71 | value = self.compute_from(instance) 72 | else: 73 | instance_compute_object = getattr(instance, self.compute_from) 74 | if callable(instance_compute_object): 75 | value = instance_compute_object() 76 | else: 77 | value = instance_compute_object 78 | return self.to_python(value) 79 | 80 | def deconstruct(self): 81 | name, path, args, kwargs = super(ComputedField, self).deconstruct() 82 | kwargs['compute_from'] = self.compute_from 83 | return name, path, args, kwargs 84 | 85 | def to_python(self, value): 86 | return super(ComputedField, self).to_python(value) 87 | 88 | def get_prep_value(self, value): 89 | return super(ComputedField, self).get_prep_value(value) 90 | 91 | 92 | class ComputedBooleanField(ComputedField, models.BooleanField): 93 | pass 94 | 95 | 96 | class ComputedCharField(ComputedField, models.CharField): 97 | pass 98 | 99 | 100 | class ComputedDateField(ComputedField, models.DateField): 101 | pass 102 | 103 | 104 | class ComputedDateTimeField(ComputedField, models.DateTimeField): 105 | pass 106 | 107 | 108 | class ComputedDecimalField(ComputedField, models.DecimalField): 109 | pass 110 | 111 | 112 | class ComputedEmailField(ComputedField, models.EmailField): 113 | pass 114 | 115 | 116 | class ComputedFloatField(ComputedField, models.FloatField): 117 | pass 118 | 119 | 120 | class ComputedIntegerField(ComputedField, models.IntegerField): 121 | pass 122 | 123 | 124 | class ComputedPositiveIntegerField(ComputedField, models.PositiveIntegerField): 125 | pass 126 | 127 | 128 | class ComputedPositiveSmallIntegerField(ComputedField, 129 | models.PositiveSmallIntegerField): 130 | pass 131 | 132 | 133 | class ComputedSmallIntegerField(ComputedField, models.SmallIntegerField): 134 | pass 135 | 136 | 137 | class ComputedTextField(ComputedField, models.TextField): 138 | pass 139 | 140 | 141 | class ComputedTimeField(ComputedField, models.TimeField): 142 | pass 143 | -------------------------------------------------------------------------------- /computed_property/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brechin/django-computed-property/9989de0d831208799ee410ff0246fc6859a2ed31/computed_property/tests/__init__.py -------------------------------------------------------------------------------- /computed_property/tests/models.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from decimal import Decimal 4 | from django.db import models 5 | 6 | import computed_property as fields 7 | 8 | 9 | def always_true(_): # Needs to consume an arg 10 | return True 11 | 12 | 13 | class ComputedBool(models.Model): 14 | computed = fields.ComputedBooleanField(compute_from=always_true) 15 | base_value = models.BooleanField(default=False) 16 | 17 | 18 | class ComputedChar(models.Model): 19 | computed = fields.ComputedCharField( 20 | max_length=25, 21 | compute_from=lambda self: 'char has %s' % self.base_value 22 | ) 23 | base_value = models.CharField( 24 | max_length=25, 25 | default='foo' 26 | ) 27 | 28 | 29 | class ComputedDate(models.Model): 30 | computed = fields.ComputedDateField( 31 | compute_from='base_value' 32 | ) 33 | base_value = models.DateField() 34 | 35 | 36 | class ComputedDateFromDateTime(models.Model): 37 | created_at = models.DateTimeField(auto_now_add=True) 38 | computed = fields.ComputedDateField( 39 | compute_from='created_at' 40 | ) 41 | 42 | 43 | class ComputedDateTime(models.Model): 44 | computed = fields.ComputedDateTimeField( 45 | compute_from=lambda self: self.base_value + datetime.timedelta(days=1) 46 | ) 47 | base_value = models.DateTimeField() 48 | 49 | 50 | class ComputedDecimal(models.Model): 51 | computed = fields.ComputedDecimalField(compute_from='convert_to_decimal', 52 | max_digits=3, 53 | decimal_places=2) 54 | base_value = models.IntegerField(default=100) 55 | 56 | def convert_to_decimal(self): 57 | return Decimal(self.base_value) / Decimal('100.0') 58 | 59 | 60 | class ComputedEmail(models.Model): 61 | computed = fields.ComputedEmailField( 62 | compute_from='prepend_test_to_email' 63 | ) 64 | base_value = models.EmailField( 65 | max_length=25, 66 | default='foo@example.com' 67 | ) 68 | 69 | def prepend_test_to_email(self): 70 | return 'test%s' % self.base_value 71 | 72 | 73 | class ComputedFloat(models.Model): 74 | computed = fields.ComputedFloatField(compute_from='convert_to_float') 75 | base_value = models.IntegerField(default=100) 76 | 77 | def convert_to_float(self): 78 | return float(self.base_value / 100.0) 79 | 80 | 81 | class AbstractInt(models.Model): 82 | base_value = models.IntegerField( 83 | default=123 84 | ) 85 | 86 | @property 87 | def compute_val(self): 88 | return self.base_value + 1000 89 | 90 | class Meta: 91 | abstract = True 92 | 93 | 94 | class ComputedInt(AbstractInt): 95 | computed = fields.ComputedIntegerField( 96 | compute_from='compute_val' 97 | ) 98 | 99 | 100 | class ComputedNullable(models.Model): 101 | computed = fields.ComputedIntegerField( 102 | null=True, 103 | compute_from=lambda x: None 104 | ) 105 | 106 | 107 | class ComputedPositiveInt(AbstractInt): 108 | computed = fields.ComputedPositiveIntegerField(compute_from='compute_val') 109 | 110 | 111 | class ComputedPositiveSmallInt(AbstractInt): 112 | computed = fields.ComputedPositiveSmallIntegerField( 113 | compute_from='compute_val' 114 | ) 115 | 116 | 117 | class ComputedSmallInt(AbstractInt): 118 | computed = fields.ComputedSmallIntegerField(compute_from='compute_val') 119 | 120 | 121 | class ComputedText(models.Model): 122 | computed = fields.ComputedTextField( 123 | compute_from=lambda x: 'char has %s' % x.base_value 124 | ) 125 | base_value = models.CharField( 126 | max_length=25, 127 | default='foo' 128 | ) 129 | 130 | 131 | class ComputedTime(models.Model): 132 | computed = fields.ComputedTimeField(compute_from='base_value') 133 | base_value = models.DateTimeField() 134 | -------------------------------------------------------------------------------- /computed_property/tests/settings/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brechin/django-computed-property/9989de0d831208799ee410ff0246fc6859a2ed31/computed_property/tests/settings/__init__.py -------------------------------------------------------------------------------- /computed_property/tests/settings/base.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | INSTALLED_APPS = [ 5 | 'computed_property.tests', 6 | ] 7 | 8 | SECRET_KEY = 'secret' 9 | SILENCED_SYSTEM_CHECKS = ['1_7.W001'] 10 | 11 | # Used to construct unique test database names to allow detox to run multiple 12 | # versions at the same time 13 | db_suffix = "_%s" % os.getuid() 14 | -------------------------------------------------------------------------------- /computed_property/tests/settings/mysql_innodb.py: -------------------------------------------------------------------------------- 1 | from .base import * # noqa 2 | 3 | import os 4 | 5 | HERE = os.path.dirname(os.path.abspath(__file__)) 6 | DB = os.path.join(HERE, 'testdb.mysql_innodb') 7 | 8 | DATABASES = { 9 | 'default': { 10 | 'ENGINE': 'django.db.backends.mysql', 11 | 'NAME': 'django_computed_property{}'.format(db_suffix), # noqa 12 | 'HOST': '127.0.0.1', 13 | 'PORT': 12345, 14 | 'USER': 'root', 15 | 'OPTIONS': {'init_command': 'SET default_storage_engine=InnoDB'}, 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /computed_property/tests/settings/postgres.py: -------------------------------------------------------------------------------- 1 | from .base import * # noqa 2 | 3 | import os 4 | import platform 5 | 6 | 7 | if platform.python_implementation().upper() == 'PYPY': 8 | from psycopg2cffi import compat 9 | compat.register() 10 | 11 | 12 | HERE = os.path.dirname(os.path.abspath(__file__)) 13 | DB = os.path.join(HERE, 'testdb.sqlite') 14 | 15 | DATABASES = { 16 | 'default': { 17 | 'ENGINE': 'django.db.backends.postgresql_psycopg2', 18 | 'NAME': 'travis_ci_test', 19 | 'USER': 'postgres', 20 | 'PASSWORD': '', 21 | 'HOST': 'localhost', 22 | 'PORT': '', 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /computed_property/tests/settings/sqlite.py: -------------------------------------------------------------------------------- 1 | from .base import * # noqa 2 | 3 | import os 4 | 5 | HERE = os.path.dirname(os.path.abspath(__file__)) 6 | DB = os.path.join(HERE, 'testdb.sqlite') 7 | 8 | DATABASES = { 9 | 'default': { 10 | 'ENGINE': 'django.db.backends.sqlite3', 11 | 'NAME': '/a-sqlite-db', 12 | }, 13 | } 14 | -------------------------------------------------------------------------------- /computed_property/tests/settings/sqlite_file.py: -------------------------------------------------------------------------------- 1 | import tempfile 2 | 3 | from .base import * # noqa 4 | 5 | import os 6 | 7 | HERE = os.path.dirname(os.path.abspath(__file__)) 8 | _fd, db_filename = tempfile.mkstemp(prefix="test_") 9 | 10 | DATABASES = { 11 | 'default': { 12 | 'ENGINE': 'django.db.backends.sqlite3', 13 | 'NAME': db_filename, 14 | 'TEST': {'NAME': db_filename}, 15 | }, 16 | } 17 | -------------------------------------------------------------------------------- /computed_property/tests/test_fields.py: -------------------------------------------------------------------------------- 1 | from datetime import date, datetime, time 2 | from decimal import Decimal 3 | 4 | import pytest 5 | from django.core.exceptions import ImproperlyConfigured 6 | 7 | from computed_property import ComputedCharField, ComputedField 8 | from . import models 9 | 10 | 11 | @pytest.mark.parametrize( 12 | 'model,vals', 13 | [ # Vals should be [base_value input, expected, 2nd non-matching input] 14 | (models.ComputedBool, 15 | [False, True]), # Second input omitted because it doesn't make sense 16 | (models.ComputedChar, 17 | ['one', 'char has one', 'test']), 18 | (models.ComputedDate, 19 | [date(2015, 2, 5), date(2015, 2, 5), date(2019, 4, 1)]), 20 | (models.ComputedDateTime, 21 | [datetime(2015, 2, 5, 15), 22 | datetime(2015, 2, 6, 15), 23 | datetime(2015, 4, 1, 15)],), 24 | (models.ComputedDecimal, 25 | [123, Decimal('1.23'), 456]), 26 | (models.ComputedEmail, 27 | ['a@example.com', 'testa@example.com', 'garbage@example.com']), 28 | (models.ComputedFloat, 29 | [123, float(1.23), 456]), 30 | (models.ComputedInt, 31 | [1, 1001, 9999]), 32 | (models.ComputedPositiveInt, 33 | [1, 1001, 9999]), 34 | (models.ComputedPositiveSmallInt, 35 | [1, 1001, 9999]), 36 | (models.ComputedSmallInt, 37 | [1, 1001, 9999]), 38 | (models.ComputedText, 39 | ['baz', 'char has baz', 'test']), 40 | (models.ComputedTime, 41 | [datetime(2018, 4, 1, 14, 13, 12), 42 | time(14, 13, 12), 43 | datetime(2018, 1, 2, 3, 4, 5)]) 44 | ], 45 | 46 | ) 47 | class TestBasicFunctionality(object): 48 | def test_create(self, db, model, vals): 49 | created = model.objects.create(base_value=vals[0]) 50 | assert vals[1] == created.computed 51 | 52 | def test_search(self, db, model, vals): 53 | created = model.objects.create(base_value=vals[0]) 54 | fetched = model.objects.get(computed=vals[1]) 55 | assert created == fetched 56 | assert created.id == fetched.id 57 | 58 | def test_save_modification(self, db, model, vals): 59 | if len(vals) < 3: 60 | return 61 | created = model.objects.create(base_value=vals[2]) 62 | assert vals[1] != created.computed 63 | created.base_value = vals[0] 64 | created.save() 65 | assert vals[1] == created.computed 66 | 67 | def test_live_modification(self, db, model, vals): 68 | if len(vals) < 3: 69 | return 70 | created = model.objects.create(base_value=vals[2]) 71 | assert vals[1] != created.computed 72 | created.base_value = vals[0] 73 | assert vals[1] == created.computed 74 | 75 | def test_access_field(self, db, model, vals): 76 | assert isinstance(model.computed, ComputedField.ObjectProxy) 77 | 78 | 79 | class TestImproperConfiguration(object): 80 | def test_null_compute_from(self): 81 | with pytest.raises(ImproperlyConfigured) as ex: 82 | class BadModel(models.ComputedText): 83 | foo = ComputedCharField() 84 | expected_error = 'ComputedCharField requires setting compute_from' 85 | assert expected_error == str(ex.value) 86 | 87 | 88 | class TestValueCoercion(object): 89 | @pytest.mark.django_db 90 | def test_date_time_to_date(self): 91 | computed_date_time_model = models.ComputedDateFromDateTime 92 | datetime_model_object = computed_date_time_model.objects.create() 93 | assert isinstance(datetime_model_object.computed, date) 94 | created_at_date = datetime_model_object.created_at.date() 95 | assert datetime_model_object.computed == created_at_date 96 | -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = DjangoComputedProperty 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /doc/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Django Computed Property documentation build configuration file, created by 5 | # sphinx-quickstart on Fri Jan 26 21:35:30 2018. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | # 20 | # import os 21 | # import sys 22 | # sys.path.insert(0, os.path.abspath('.')) 23 | 24 | 25 | # -- General configuration ------------------------------------------------ 26 | 27 | # If your documentation needs a minimal Sphinx version, state it here. 28 | # 29 | # needs_sphinx = '1.0' 30 | 31 | # Add any Sphinx extension module names here, as strings. They can be 32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 33 | # ones. 34 | extensions = [] 35 | 36 | # Add any paths that contain templates here, relative to this directory. 37 | templates_path = ['_templates'] 38 | 39 | # The suffix(es) of source filenames. 40 | # You can specify multiple suffix as a list of string: 41 | # 42 | # source_suffix = ['.rst', '.md'] 43 | source_suffix = '.rst' 44 | 45 | # The master toctree document. 46 | master_doc = 'index' 47 | 48 | # General information about the project. 49 | project = 'Django Computed Property' 50 | copyright = '2018, Jason Brechin' 51 | author = 'Jason Brechin' 52 | 53 | # The version info for the project you're documenting, acts as replacement for 54 | # |version| and |release|, also used in various other places throughout the 55 | # built documents. 56 | # 57 | # The short X.Y version. 58 | version = '0.1' 59 | # The full version, including alpha/beta/rc tags. 60 | release = '0.1' 61 | 62 | # The language for content autogenerated by Sphinx. Refer to documentation 63 | # for a list of supported languages. 64 | # 65 | # This is also used if you do content translation via gettext catalogs. 66 | # Usually you set "language" from the command line for these cases. 67 | language = None 68 | 69 | # List of patterns, relative to source directory, that match files and 70 | # directories to ignore when looking for source files. 71 | # This patterns also effect to html_static_path and html_extra_path 72 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 73 | 74 | # The name of the Pygments (syntax highlighting) style to use. 75 | pygments_style = 'sphinx' 76 | 77 | # If true, `todo` and `todoList` produce output, else they produce nothing. 78 | todo_include_todos = False 79 | 80 | 81 | # -- Options for HTML output ---------------------------------------------- 82 | 83 | # The theme to use for HTML and HTML Help pages. See the documentation for 84 | # a list of builtin themes. 85 | # 86 | html_theme = 'alabaster' 87 | 88 | # Theme options are theme-specific and customize the look and feel of a theme 89 | # further. For a list of options available for each theme, see the 90 | # documentation. 91 | # 92 | # html_theme_options = {} 93 | 94 | # Add any paths that contain custom static files (such as style sheets) here, 95 | # relative to this directory. They are copied after the builtin static files, 96 | # so a file named "default.css" will overwrite the builtin "default.css". 97 | #html_static_path = ['_static'] 98 | 99 | # Custom sidebar templates, must be a dictionary that maps document names 100 | # to template names. 101 | # 102 | # This is required for the alabaster theme 103 | # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars 104 | html_sidebars = { 105 | '**': [ 106 | 'relations.html', # needs 'show_related': True theme option to display 107 | 'searchbox.html', 108 | ] 109 | } 110 | 111 | 112 | # -- Options for HTMLHelp output ------------------------------------------ 113 | 114 | # Output file base name for HTML help builder. 115 | htmlhelp_basename = 'DjangoComputedPropertydoc' 116 | 117 | 118 | # -- Options for LaTeX output --------------------------------------------- 119 | 120 | latex_elements = { 121 | # The paper size ('letterpaper' or 'a4paper'). 122 | # 123 | # 'papersize': 'letterpaper', 124 | 125 | # The font size ('10pt', '11pt' or '12pt'). 126 | # 127 | # 'pointsize': '10pt', 128 | 129 | # Additional stuff for the LaTeX preamble. 130 | # 131 | # 'preamble': '', 132 | 133 | # Latex figure (float) alignment 134 | # 135 | # 'figure_align': 'htbp', 136 | } 137 | 138 | # Grouping the document tree into LaTeX files. List of tuples 139 | # (source start file, target name, title, 140 | # author, documentclass [howto, manual, or own class]). 141 | latex_documents = [ 142 | (master_doc, 'DjangoComputedProperty.tex', 'Django Computed Property Documentation', 143 | 'Jason Brechin', 'manual'), 144 | ] 145 | 146 | 147 | # -- Options for manual page output --------------------------------------- 148 | 149 | # One entry per manual page. List of tuples 150 | # (source start file, name, description, authors, manual section). 151 | man_pages = [ 152 | (master_doc, 'djangocomputedproperty', 'Django Computed Property Documentation', 153 | [author], 1) 154 | ] 155 | 156 | 157 | # -- Options for Texinfo output ------------------------------------------- 158 | 159 | # Grouping the document tree into Texinfo files. List of tuples 160 | # (source start file, target name, title, author, 161 | # dir menu entry, description, category) 162 | texinfo_documents = [ 163 | (master_doc, 'DjangoComputedProperty', 'Django Computed Property Documentation', 164 | author, 'DjangoComputedProperty', 'One line description of project.', 165 | 'Miscellaneous'), 166 | ] 167 | 168 | 169 | 170 | -------------------------------------------------------------------------------- /doc/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to django-computed-property! 2 | ==================================================== 3 | 4 | Computed Property fields for Django models, inspired by `Google Cloud NDB`_ 5 | 6 | .. _Google Cloud NDB: https://cloud.google.com/appengine/docs/standard/python/ndb/entity-property-reference#computed 7 | 8 | 9 | Prerequisites 10 | ------------- 11 | 12 | ``django-computed-property`` supports (i.e. is tested on) `Django`_ 1.8 - 2.2 and trunk on Python 2.7, 13 | 3.4, 3.5, 3.6, 3.7, pypy, and pypy3. 14 | 15 | SQLite and Postgres are currently tested, but any Django database backend should work. 16 | 17 | .. _Django: http://www.djangoproject.com/ 18 | 19 | 20 | Installation 21 | ------------ 22 | 23 | ``django-computed-property`` is available on `PyPI`_. Install it with:: 24 | 25 | pip install django-computed-property 26 | 27 | .. _PyPI: https://pypi.python.org/pypi/django-computed-property/ 28 | 29 | 30 | Usage 31 | ----- 32 | 33 | Add computed_property to your list of `INSTALLED_APPS` in `settings.py`:: 34 | 35 | INSTALLED_APPS = [ 36 | ... 37 | 'computed_property' 38 | ] 39 | 40 | Then, simply import and use the included field classes in your models:: 41 | 42 | from django.db import models 43 | from computed_property import ComputedTextField 44 | 45 | 46 | class MyModel(models.Model): 47 | name = ComputedTextField(compute_from='calculation') 48 | 49 | @property 50 | def calculation(self): 51 | return 'some complicated stuff' 52 | 53 | You can read values from the ``name`` field as usual, but you may not set the field's value. 54 | When the field is accessed and when a model instance is saved, it will compute the field's value 55 | using the provided callable (function/lambda), property name, or attribute name. 56 | 57 | `compute_from` can be a reference to a function that takes a single argument (an instance of the model), or 58 | a string referring to a field, property, or other attribute on the instance. 59 | 60 | 61 | .. note:: 62 | 63 | It is important to note that your computed field data will not immediately be written to the database. 64 | You must (re-)save all instances of your data to have the computed fields populated in the database. Until 65 | you do so, you will be able to access those fields when you load an instance of the model, but 66 | you will not benefit from their queryability. 67 | 68 | One way you could do this is in a data migration, using something like:: 69 | 70 | for instance in MyModel.objects.all().iterator(): 71 | instance.save() 72 | 73 | 74 | Field types 75 | ~~~~~~~~~~~ 76 | 77 | Several other field classes are included: ``ComputedCharField``, 78 | ``ComputedEmailField``, ``ComputedIntegerField``, ``ComputedDateField``, 79 | ``ComputedDateTimeField``, and others. All field classes accept the same arguments as 80 | their non-Computed versions. 81 | 82 | To create an Computed version of some other field class, inherit from 83 | both ``ComputedField`` and the other field class:: 84 | 85 | from computed_property import ComputedField 86 | from somewhere import MyField 87 | 88 | class MyComputedField(ComputedField, MyField): 89 | pass 90 | 91 | 92 | Contributing 93 | ------------ 94 | 95 | Please also refer to the `contributing docs`_ in the repository. 96 | 97 | .. _contributing docs: https://github.com/brechin/django-computed-property/blob/master/CONTRIBUTING.rst 98 | 99 | On top of the above, developers please consider the following: 100 | 101 | Reporting Issues or Suggestions 102 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 103 | 104 | If you see an issue with the library or have a request for new functionality, please file an issue in GitHub. 105 | 106 | Please include specifics about which version of Python you're using, as well as which version of the django-computed-property library you're using. 107 | 108 | Whenever possible, include a code sample that demonstrates the issue you're seeing or the desired developer experience. 109 | 110 | Adding Test Cases 111 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 112 | 113 | The complete test suite is run using ``tox``. This is how tests are run on Travis-CI, it includes all 114 | supported Python versions, all supported databases back ends, and all supported Django versions. 115 | Arguably not what you would want to do, each time you add a test case, or make a minor change. 116 | 117 | To run the test suite in just one version of Python, against sqlite3, and using one chosen Django 118 | version, you still use ``tox``, instructing it to just test that single configuration combination. 119 | 120 | You can enumerate all the build configurations with ``tox -l``. From that list, you can choose the 121 | combination of python-django-database (or the ``py37-docs`` or ``py37-flake8`` builds) to run. 122 | 123 | For example:: 124 | 125 | $ tox -e py37-django111-sqlite 126 | 127 | Tox generally just requires that the version of python you're testing against is installed on your 128 | system. It will take care of creating the test environment from the configuration information in 129 | ``tox.ini``. 130 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | attrs==17.4.0 2 | coverage>=3.7.1 3 | Django>=1.8.2 4 | flake8>=2.4.1 5 | funcsigs==1.0.2 6 | pluggy>=0.7 7 | py>=1.5.2 8 | pytest>=2.7.3,<4 9 | pytest-django>=2.8.0 10 | pytz>=2017.3 11 | six>=1.11.0 12 | Sphinx>=1.3.1 13 | tox>=2.0.1 14 | virtualenv==15.1.0 -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # This script exists so this dir is on sys.path when running pytest in tox. 3 | import pytest 4 | import os 5 | import sys 6 | 7 | os.environ.setdefault( 8 | 'DJANGO_SETTINGS_MODULE', 'computed_property.tests.settings.sqlite') 9 | 10 | sys.exit(pytest.main()) 11 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from os.path import join 2 | from setuptools import setup, find_packages 3 | 4 | 5 | long_description = 'Computed property fields for Django' 6 | 7 | 8 | def get_version(): 9 | with open(join('computed_property', '__init__.py')) as f: 10 | for line in f: 11 | if line.startswith('__version__ ='): 12 | return line.split('=')[1].strip().strip('"\'') 13 | 14 | 15 | setup( 16 | name='django-computed-property', 17 | version=get_version(), 18 | description="Computed property model fields for Django", 19 | long_description=long_description, 20 | license='MIT', 21 | author='Jason Brechin', 22 | author_email='brechinj@gmail.com', 23 | url='https://github.com/brechin/django-computed-property/', 24 | packages=find_packages(exclude=["*.tests", "*.tests.*"]), 25 | install_requires=['Django>=1.8.2', 'six>=1.11.0'], 26 | classifiers=[ 27 | 'Environment :: Web Environment', 28 | 'Intended Audience :: Developers', 29 | 'License :: OSI Approved :: MIT License', 30 | 'Operating System :: OS Independent', 31 | 'Programming Language :: Python', 32 | 'Programming Language :: Python :: 2', 33 | 'Programming Language :: Python :: 2.7', 34 | 'Programming Language :: Python :: 3', 35 | 'Programming Language :: Python :: 3.4', 36 | 'Programming Language :: Python :: 3.5', 37 | 'Programming Language :: Python :: 3.6', 38 | 'Programming Language :: Python :: 3.7', 39 | 'Programming Language :: Python :: Implementation :: CPython', 40 | 'Programming Language :: Python :: Implementation :: PyPy', 41 | 'Framework :: Django', 42 | ], 43 | ) 44 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py27-{flake8,coverage} 4 | py37-{docs,flake8,coverage}, 5 | pypy-django{18,19,110,111}-{sqlite,sqlite_file,postgres}, 6 | py27-django{18,19,110,111}-{sqlite,sqlite_file,postgres}, 7 | py34-django{18,19,110,111,20}-{sqlite,sqlite_file,postgres}, 8 | py35-django{18,19,110,111,20,21,22}-{sqlite,sqlite_file,postgres}, 9 | py{36,37,py3}-django{111,20,21,22,trunk}-{sqlite,sqlite_file,postgres}, 10 | 11 | [testenv] 12 | 13 | basepython = 14 | py27: python2.7 15 | py34: python3.4 16 | py35: python3.5 17 | py36: python3.6 18 | py37: python3.7 19 | 20 | pypy: pypy 21 | pypy3: pypy3 22 | 23 | passenv = TRAVIS TRAVIS_JOB_ID TRAVIS_BRANCH 24 | 25 | deps = 26 | coveralls 27 | pytest<4.0 28 | pytest-django 29 | py 30 | six>=1.11.0 31 | 32 | coverage: coverage==4.4.2 33 | 34 | django18: Django>=1.8,<1.9 35 | django19: Django>=1.9,<1.10 36 | django110: Django>=1.10,<1.11 37 | django111: Django>=1.11a1,<2 38 | django20: Django>=2,<2.1 39 | django21: Django>=2.1,<2.2 40 | django22: Django>=2.2,<2.3 41 | djangotrunk: https://github.com/django/django/tarball/master 42 | 43 | pypy,pypy3: psycopg2cffi 44 | py{27,34,35,36,37}: psycopg2-binary 45 | mysql_myisam: mysqlclient 46 | mysql_innodb: mysqlclient 47 | 48 | setenv = 49 | PYTHONWARNINGS=module::DeprecationWarning 50 | sqlite: DJANGO_SETTINGS_MODULE = computed_property.tests.settings.sqlite 51 | sqlite_file: DJANGO_SETTINGS_MODULE = computed_property.tests.settings.sqlite_file 52 | mysql_innodb: DJANGO_SETTINGS_MODULE = computed_property.tests.settings.mysql_innodb 53 | mysql_myisam: DJANGO_SETTINGS_MODULE = computed_property.tests.settings.mysql_myisam 54 | postgres: DJANGO_SETTINGS_MODULE = computed_property.tests.settings.postgres 55 | 56 | commands = 57 | !coverage: ./runtests.py computed_property/tests 58 | coverage: coverage erase && coverage run -a runtests.py computed_property/tests 59 | 60 | [testenv:py27-flake8] 61 | deps = flake8 62 | changedir = {toxinidir} 63 | commands = flake8 . 64 | 65 | [testenv:py37-flake8] 66 | deps = flake8 67 | changedir = {toxinidir} 68 | commands = flake8 . 69 | 70 | [testenv:py37-docs] 71 | deps = Sphinx 72 | changedir = {toxinidir}/doc 73 | commands = 74 | sphinx-build -aEWq -b html . _build/html 75 | 76 | [flake8] 77 | exclude = .tox,.git,__pycache__,doc/conf.py,venv/ 78 | --------------------------------------------------------------------------------