├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.md ├── changelog.md ├── setup.py └── src ├── drynk ├── __init__.py ├── decorators.py └── tests │ ├── __init__.py │ └── models.py ├── runtests.py └── tests ├── __init__.py ├── test_settings.py └── tests.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | 47 | # Translations 48 | *.mo 49 | *.pot 50 | 51 | # Django stuff: 52 | *.log 53 | 54 | # Sphinx documentation 55 | docs/_build/ 56 | 57 | # PyBuilder 58 | target/ -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - 3.4 4 | - 3.5 5 | - nightly 6 | env: 7 | - DJANGO_VERSION=1.8.15 8 | - DJANGO_VERSION=1.9.10 9 | - DJANGO_VERSION=1.10.2 10 | install: 11 | - pip install -q Django==$DJANGO_VERSION 12 | - python setup.py -q install 13 | script: python src/runtests.py 14 | matrix: 15 | allow_failures: 16 | - python: nightly 17 | notifications: 18 | email: 19 | on_success: change 20 | on_failure: change -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Matt Cooper and contributors 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 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/vtbassmatt/django-drynk.svg?branch=master)](https://travis-ci.org/vtbassmatt/django-drynk) 2 | [![PyPI](https://img.shields.io/pypi/v/django-drynk.svg)](https://pypi.python.org/pypi/django-drynk) 3 | 4 | Introduction 5 | ------------ 6 | 7 | DRYNK: for DRY Natural Keys. 8 | To use [Django natural keys](https://docs.djangoproject.com/en/1.9/topics/serialization/#natural-keys), you end up repeating yourself. 9 | You have to define a `natural_key` method on the Model and a `get_by_natural_key` method on the Manager, but they both contain the same fields! 10 | With DRYNK, instead you add a single decorator to your model which takes care of everything. 11 | 12 | The old way: 13 | 14 | class Thing(models.Model): 15 | name = models.CharField(max_length=5, unique=True) 16 | some_data = models.IntegerField() 17 | 18 | objects = models.Manager() 19 | objects.get_by_natural_key = lambda self, x: return self.get(name=x) 20 | 21 | def natural_key(self): 22 | return self.name 23 | 24 | But you've got DRYNK: 25 | 26 | @with_natural_key(["name"]) 27 | class Thing(models.Model): 28 | name = models.CharField(max_length=50, unique=True) 29 | some_data = models.IntegerField() 30 | 31 | 32 | Requirements and Installation 33 | ----------------------------- 34 | 35 | The project has no dependencies outside of Django itself. 36 | It works with Python 3.4 / 3.5 on Django 1.8 / 1.9. 37 | 38 | Django 1.10 changed an internal API and I haven't had a chance to fix it. 39 | The django-mptt folks hit similar issues, so I should look at what they did: https://github.com/django-mptt/django-mptt/blob/master/docs/upgrade.rst#086 40 | 41 | * `pip install django-drynk` 42 | * Add `from drynk import with_natural_key` to your `models.py` file 43 | 44 | 45 | Use 46 | --- 47 | 48 | from django.db import models 49 | from drynk import with_natural_key 50 | 51 | @with_natural_key(["name"]) 52 | class Thing(models.Model): 53 | name = models.CharField(max_length=50, unique=True) 54 | some_data = models.IntegerField() 55 | 56 | Also works with fields that are foreign keys, so long as those foreign objects also have a natural key. 57 | 58 | from django.db import models 59 | from drynk import with_natural_key 60 | 61 | @with_natural_key(["name"]) 62 | class Kind(models.Model): 63 | name = models.CharField(max_length=10, unique=True) 64 | 65 | @with_natural_key(["name", "kind"]) 66 | class Thing(models.Model): 67 | name = models.CharField(max_length=50) 68 | kind = models.ForeignKey(Kind) 69 | 70 | 71 | Tests 72 | ----- 73 | 74 | `./run-tests.sh`. 75 | 76 | 77 | Limitations 78 | ----------- 79 | 80 | * Is opinionated: Believes it should be the default Manager and be called `object`. 81 | * Probably breaks if you've already specified a manager, even by another name, on the model class. 82 | * Requires Python3. I like the `raise Exception from` syntax and `inspect.signature`. 83 | 84 | 85 | Contributions 86 | ------------- 87 | 88 | I built this little project to satisfy a personal need, but thought it might be useful enough for others. 89 | If you have contributions, please don't hesitate to send a PR. 90 | Let's keep the tests passing and all will be well. 91 | My personal stack is currently Django 1.9 on Python 3.4, so that will be the most-tested. 92 | Travis will cover Django 1.8 and 1.9 on Python 3.4, 3.5, and nightly. 93 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | 0.1.0 2 | ----- 3 | * Initial package version -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup, find_packages 3 | 4 | version = '0.1.2' 5 | README = """ 6 | django-drynk gives you DRY natural keys. 7 | 8 | Instead of defining a `natural_key` method on the Model and a `get_by_natural_key` method on the Manager, instead you add a simple decorator to your model which takes care of everything. 9 | """ 10 | 11 | # allow setup.py to be run from any path 12 | os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) 13 | 14 | setup( 15 | name = 'django-drynk', 16 | version = version, 17 | description = 'django-drynk gives you DRY natural keys.', 18 | long_description = README, 19 | keywords = 'django natural_key', 20 | license = 'MIT License', 21 | author = 'Matt Cooper', 22 | author_email = 'vtbassmatt@gmail.com', 23 | url = 'http://github.com/vtbassmatt/django-drynk/', 24 | install_requires = ['django'], 25 | classifiers=[ 26 | 'Development Status :: 4 - Beta', 27 | 'Environment :: Plugins', 28 | 'Environment :: Web Environment', 29 | 'Framework :: Django', 30 | 'Intended Audience :: Developers', 31 | 'License :: OSI Approved :: MIT License', 32 | 'Programming Language :: Python', 33 | 'Programming Language :: Python :: 3', 34 | 'Programming Language :: Python :: 3.4', 35 | 'Programming Language :: Python :: 3.5', 36 | 'Topic :: Software Development :: Libraries :: Python Modules', 37 | ], 38 | package_dir = {'': 'src'}, 39 | packages = ['drynk'], 40 | include_package_data = True, 41 | ) 42 | 43 | -------------------------------------------------------------------------------- /src/drynk/__init__.py: -------------------------------------------------------------------------------- 1 | from pkg_resources import get_distribution, DistributionNotFound 2 | 3 | try: 4 | _dist = get_distribution('django-drynk') 5 | except DistributionNotFound: 6 | __version__ = 'Please install this project with setup.py' 7 | else: 8 | __version__ = _dist.version 9 | VERSION = __version__ 10 | 11 | from .decorators import with_natural_key 12 | -------------------------------------------------------------------------------- /src/drynk/decorators.py: -------------------------------------------------------------------------------- 1 | """ 2 | Decorators for Django models 3 | """ 4 | from functools import reduce 5 | from inspect import signature, Parameter 6 | 7 | from django.core.exceptions import ImproperlyConfigured 8 | from django.db import models 9 | 10 | 11 | def with_natural_key(fields): 12 | """ 13 | Decorator to add DRY natural key support to a Django model. 14 | 15 | Adds a Manager class with get_by_natural_key and a corresponding natural_key 16 | method on the model. 17 | """ 18 | assert len(fields) > 0 19 | assert 'self' not in fields # for NaturalKeyManager 20 | 21 | def natural_key_wrapper(klass): 22 | def _natural_key_field(self, field_name): 23 | if isinstance(self._meta.get_field(field_name), models.fields.related.ForeignKey): 24 | real_field = getattr(self, self._meta.get_field(field_name).name) 25 | if getattr(real_field, 'natural_key', None): 26 | return real_field.natural_key() 27 | else: 28 | # if the foreign model can't provide a natural key, then the 29 | # auto-generated get_by_natural_key won't work 30 | raise ImproperlyConfigured("expected to find a `natural_key` method " 31 | "on {}".format(field_name)) 32 | return (self.__dict__[field_name], ) 33 | 34 | def _natural_key(self): 35 | return reduce(lambda x, y: x + y, [self._natural_key_field(x) for x in fields]) 36 | 37 | klass._natural_key_field = _natural_key_field 38 | klass.natural_key = _natural_key 39 | klass.natural_key.__name__ = "natural_key" 40 | klass.natural_key.__doc__ = "Return a natural key for the model: ({})".format( 41 | ", ".join(fields)) 42 | 43 | # determine and record dependencies 44 | # also, unroll dependencies and read their get_by_natural_key signatures 45 | dependencies = [] 46 | unrolled_fields = [] 47 | 48 | for field_name in fields: 49 | if isinstance(klass._meta.get_field(field_name), models.fields.related.ForeignKey): 50 | try: 51 | # Django 1.9 52 | to_model = klass._meta.get_field(field_name).remote_field.to 53 | except AttributeError: 54 | # Django 1.8 55 | to_model = klass._meta.get_field(field_name).related_field.model 56 | dependencies.append(_build_dependency(to_model, field_name)) 57 | unrolled_fields.extend(_unroll_natural_key(to_model, field_name)) 58 | else: 59 | unrolled_fields.append(field_name) 60 | 61 | if len(dependencies) > 0: 62 | klass.natural_key.dependencies = dependencies 63 | 64 | 65 | class NaturalKeyManager(models.Manager): 66 | """ 67 | Implements get_by_natural_key for {} 68 | """ 69 | def get_by_natural_key(self, *args): 70 | """ 71 | Find an object by its natural key 72 | """ 73 | if len(args) != len(unrolled_fields): 74 | raise RuntimeError("expected {} arguments ({}), got {}".format( 75 | len(unrolled_fields), ", ".join(unrolled_fields), len(args) 76 | )) 77 | 78 | return self.get(**dict(zip(unrolled_fields, args))) 79 | 80 | NaturalKeyManager.__name__ = klass.__name__ + "NaturalKeyManager" 81 | NaturalKeyManager.__doc__ = NaturalKeyManager.__doc__.format(klass.__name__) 82 | 83 | # fix up the signature to show the expected positional parameters instead of "*args" 84 | # this cleans up help() but also allows later models to use this in constructing 85 | # their get_by_natural_key method 86 | sig = signature(NaturalKeyManager.get_by_natural_key) 87 | sig = sig.replace(parameters=[Parameter(f, Parameter.POSITIONAL_ONLY) for f in ['self'] + unrolled_fields]) 88 | setattr(NaturalKeyManager.get_by_natural_key, "__signature__", sig) 89 | 90 | _m = NaturalKeyManager() 91 | # opinionated choice here: this should be the default manager and be called `objects` 92 | # TODO: Django 1.10 rewrote how contribute_to_class works and I haven't had time to understand it yet.' 93 | _m.contribute_to_class(klass, "objects") 94 | 95 | return klass 96 | 97 | return natural_key_wrapper 98 | 99 | 100 | def _build_dependency(model, field_name): 101 | app_name = model._meta.app_config.label 102 | model_name = model.__name__ 103 | return "{}.{}".format(app_name, model_name) 104 | 105 | 106 | def _unroll_natural_key(model, field_name): 107 | try: 108 | foreign_gbnk = model.objects.get_by_natural_key 109 | except AttributeError as e: 110 | err_str = ("expected to find a manager on %r called `objects`" 111 | " with a `get_by_natural_key` method") % (model,) 112 | raise ImproperlyConfigured(err_str) from e 113 | sig = signature(foreign_gbnk) 114 | return ["{}__{}".format(field_name, k) for k,v in sig.parameters.items()] 115 | -------------------------------------------------------------------------------- /src/drynk/tests/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/drynk/tests/models.py: -------------------------------------------------------------------------------- 1 | """ 2 | Models for testing DRYNK 3 | """ 4 | from django.db import models 5 | 6 | from drynk import with_natural_key 7 | 8 | 9 | @with_natural_key(["name"]) 10 | class TestModel1(models.Model): 11 | name = models.CharField(max_length=50) 12 | 13 | 14 | @with_natural_key(["name", "size"]) 15 | class TestModel2(models.Model): 16 | name = models.CharField(max_length=50) 17 | size = models.IntegerField() 18 | 19 | 20 | @with_natural_key(["name", "size", "stocked"]) 21 | class TestModel3(models.Model): 22 | name = models.CharField(max_length=50) 23 | size = models.IntegerField() 24 | stocked = models.BooleanField(default=False) 25 | 26 | 27 | @with_natural_key(["title"]) 28 | class Category1(models.Model): 29 | title = models.CharField(max_length=50) 30 | 31 | 32 | @with_natural_key(["name", "category"]) 33 | class Widget1(models.Model): 34 | name = models.CharField(max_length=50) 35 | category = models.ForeignKey(Category1) 36 | 37 | 38 | @with_natural_key(["name", "widget"]) 39 | class SubWidget(models.Model): 40 | name = models.CharField(max_length=50) 41 | widget = models.ForeignKey(Widget1) 42 | 43 | 44 | @with_natural_key(["size", "color"]) 45 | class Category2(models.Model): 46 | size = models.IntegerField() 47 | color = models.CharField(max_length=50) 48 | 49 | 50 | @with_natural_key(["name", "category"]) 51 | class Widget2(models.Model): 52 | name = models.CharField(max_length=50) 53 | category = models.ForeignKey(Category2) 54 | -------------------------------------------------------------------------------- /src/runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | import django 6 | from django.conf import settings 7 | from django.test.utils import get_runner 8 | 9 | if __name__ == "__main__": 10 | os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.test_settings' 11 | django.setup() 12 | TestRunner = get_runner(settings) 13 | test_runner = TestRunner() 14 | failures = test_runner.run_tests(["tests"]) 15 | sys.exit(bool(failures)) 16 | -------------------------------------------------------------------------------- /src/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vtbassmatt/django-drynk/976a62f6e058c766cd40a3084a7436c978050c68/src/tests/__init__.py -------------------------------------------------------------------------------- /src/tests/test_settings.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = 'tests' 2 | INSTALLED_APPS = [ 3 | "drynk", 4 | "drynk.tests", 5 | ] 6 | DATABASES = { 7 | 'default': { 8 | 'ENGINE': 'django.db.backends.sqlite3', 9 | 'NAME': 'drynk.sqlite3', 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/tests/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from drynk import with_natural_key 4 | from drynk.tests.models import ( 5 | TestModel1, 6 | TestModel2, 7 | TestModel3, 8 | Category1, 9 | Widget1, 10 | Category2, 11 | Widget2, 12 | SubWidget) 13 | 14 | 15 | class SanityTests(TestCase): 16 | 17 | def test_reality(self): 18 | """ 19 | Make sure nothing broke just by importing. 20 | """ 21 | self.assertTrue(True) 22 | 23 | 24 | class NaturalKeyTests(TestCase): 25 | 26 | def test_single(self): 27 | """ 28 | One field natural key. 29 | """ 30 | tm1 = TestModel1(name="Widget 1") 31 | tm2 = TestModel1(name="Widget 2") 32 | 33 | self.assertEqual(tm1.natural_key(), ('Widget 1', )) 34 | self.assertEqual(tm2.natural_key(), ('Widget 2', )) 35 | 36 | def test_two(self): 37 | """ 38 | Two field natural key. 39 | """ 40 | tm1 = TestModel2(name="Widget 1", size=5) 41 | tm2 = TestModel2(name="Widget 2", size=10) 42 | 43 | self.assertEqual(tm1.natural_key(), ('Widget 1', 5)) 44 | self.assertEqual(tm2.natural_key(), ('Widget 2', 10)) 45 | 46 | def test_three(self): 47 | """ 48 | Three field natural key. 49 | """ 50 | tm1 = TestModel3(name="Widget 1", size=5, stocked=False) 51 | tm2 = TestModel3(name="Widget 2", size=10, stocked=True) 52 | 53 | self.assertEqual(tm1.natural_key(), ('Widget 1', 5, False)) 54 | self.assertEqual(tm2.natural_key(), ('Widget 2', 10, True)) 55 | 56 | def test_foreign(self): 57 | """ 58 | Natural key spanning a foreign relationship 59 | """ 60 | cat1 = Category1(title="Category 1") 61 | cat2 = Category1(title="Category 2") 62 | widget1 = Widget1(name="Widget X", category=cat1) 63 | widget2 = Widget1(name="Widget X", category=cat2) 64 | widget3 = Widget1(name="Widget Y", category=cat1) 65 | 66 | self.assertEqual(widget1.natural_key(), ('Widget X', 'Category 1')) 67 | self.assertEqual(widget2.natural_key(), ('Widget X', 'Category 2')) 68 | self.assertEqual(widget3.natural_key(), ('Widget Y', 'Category 1')) 69 | 70 | def test_foreign_multiple(self): 71 | """ 72 | Natural key spanning a foreign relationship (two fields) 73 | """ 74 | cat1 = Category2(size=1, color="red") 75 | cat2 = Category2(size=1, color="blue") 76 | cat3 = Category2(size=5, color="blue") 77 | widget1 = Widget2(name="Widget X", category=cat1) 78 | widget2 = Widget2(name="Widget X", category=cat2) 79 | widget3 = Widget2(name="Widget X", category=cat3) 80 | 81 | self.assertEqual(widget1.natural_key(), ('Widget X', 1, 'red')) 82 | self.assertEqual(widget2.natural_key(), ('Widget X', 1, 'blue')) 83 | self.assertEqual(widget3.natural_key(), ('Widget X', 5, 'blue')) 84 | 85 | def test_foreign_foreign(self): 86 | """ 87 | Natural key spanning two foreign relationships 88 | """ 89 | cat1 = Category1(title="Category") 90 | widget1 = Widget1(name="Widget X", category=cat1) 91 | widget2 = Widget1(name="Widget Y", category=cat1) 92 | sub1 = SubWidget(name="Sub A", widget=widget1) 93 | sub2 = SubWidget(name="Sub A", widget=widget2) 94 | sub3 = SubWidget(name="Sub B", widget=widget2) 95 | 96 | self.assertEqual(sub1.natural_key(), ('Sub A', 'Widget X', 'Category')) 97 | self.assertEqual(sub2.natural_key(), ('Sub A', 'Widget Y', 'Category')) 98 | self.assertEqual(sub3.natural_key(), ('Sub B', 'Widget Y', 'Category')) 99 | 100 | 101 | class ManagerTests(TestCase): 102 | 103 | def test_basic(self): 104 | """ 105 | Lookup by one-field natural key. 106 | """ 107 | tm1 = TestModel1.objects.create(name="Widget 1") 108 | tm2 = TestModel1.objects.create(name="Widget 2") 109 | 110 | self.assertEqual(TestModel1.objects.get_by_natural_key('Widget 1'), tm1) 111 | self.assertEqual(TestModel1.objects.get_by_natural_key('Widget 2'), tm2) 112 | with self.assertRaises(TestModel1.DoesNotExist): 113 | TestModel1.objects.get_by_natural_key('Widget X') 114 | 115 | def test_two(self): 116 | """ 117 | Lookup by two-field natural key. 118 | """ 119 | tm1 = TestModel2.objects.create(name="Widget 1", size=5) 120 | tm2 = TestModel2.objects.create(name="Widget 2", size=10) 121 | 122 | self.assertEqual(TestModel2.objects.get_by_natural_key('Widget 1', 5), tm1) 123 | self.assertEqual(TestModel2.objects.get_by_natural_key('Widget 2', 10), tm2) 124 | with self.assertRaises(TestModel2.DoesNotExist): 125 | TestModel2.objects.get_by_natural_key('Widget 1', 10) 126 | 127 | def test_foreign(self): 128 | """ 129 | Lookup by a key spanning a foreign relationship 130 | """ 131 | cat1 = Category1.objects.create(title="Category 1") 132 | cat2 = Category1.objects.create(title="Category 2") 133 | widget1 = Widget1.objects.create(name="Widget X", category=cat1) 134 | widget2 = Widget1.objects.create(name="Widget X", category=cat2) 135 | widget3 = Widget1.objects.create(name="Widget Y", category=cat1) 136 | 137 | self.assertEqual(Widget1.objects.get_by_natural_key('Widget X', 'Category 1'), widget1) 138 | self.assertEqual(Widget1.objects.get_by_natural_key('Widget X', 'Category 2'), widget2) 139 | self.assertEqual(Widget1.objects.get_by_natural_key('Widget Y', 'Category 1'), widget3) 140 | with self.assertRaises(Widget1.DoesNotExist): 141 | Widget1.objects.get_by_natural_key('Widget Y', 'Category 2') 142 | --------------------------------------------------------------------------------