├── MANIFEST.in ├── .gitignore ├── forkit ├── tools.py ├── tests │ ├── __init__.py │ ├── settings.py │ ├── receivers.py │ ├── coverage_test.py │ ├── fixtures │ │ └── test_data.json │ ├── models.py │ ├── signals.py │ ├── reset.py │ ├── diff.py │ ├── utils.py │ ├── fork.py │ └── colorize.py ├── models.py ├── signals.py ├── __init__.py ├── diff.py ├── commit.py ├── reset.py ├── fork.py └── utils.py ├── LICENSE ├── CHANGELOG ├── setup.py └── README.md /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.pyc 3 | MANIFEST 4 | dist 5 | -------------------------------------------------------------------------------- /forkit/tools.py: -------------------------------------------------------------------------------- 1 | from forkit.diff import diff_model_object as diff 2 | from forkit.fork import fork_model_object as fork 3 | from forkit.reset import reset_model_object as reset 4 | from forkit.commit import commit_model_object as commit 5 | -------------------------------------------------------------------------------- /forkit/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from forkit.tests.models import * 2 | from forkit.tests.utils import * 3 | from forkit.tests.diff import * 4 | from forkit.tests.fork import * 5 | from forkit.tests.reset import * 6 | from forkit.tests.signals import * 7 | -------------------------------------------------------------------------------- /forkit/tests/settings.py: -------------------------------------------------------------------------------- 1 | DATABASES = { 2 | 'default': { 3 | 'ENGINE': 'django.db.backends.sqlite3', 4 | 'NAME': 'forkit.db', 5 | } 6 | } 7 | 8 | INSTALLED_APPS = ( 9 | 'forkit', 10 | 'forkit.tests' 11 | ) 12 | 13 | COVERAGE_MODULES = ( 14 | 'forkit.diff', 15 | 'forkit.fork', 16 | 'forkit.reset', 17 | 'forkit.commit', 18 | 'forkit.utils', 19 | ) 20 | 21 | TEST_RUNNER = 'forkit.tests.coverage_test.CoverageTestRunner' 22 | -------------------------------------------------------------------------------- /forkit/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from forkit import tools 3 | 4 | class ForkableModel(models.Model): 5 | "Convenience subclass which builds in the public Forkit utilities." 6 | def diff(self, *args, **kwargs): 7 | return tools.diff(self, *args, **kwargs) 8 | 9 | def fork(self, *args, **kwargs): 10 | return tools.fork(self, *args, **kwargs) 11 | 12 | def reset(self, *args, **kwargs): 13 | return tools.reset(self, *args, **kwargs) 14 | 15 | def commit(self): 16 | tools.commit(self) 17 | 18 | class Meta(object): 19 | abstract = True 20 | 21 | -------------------------------------------------------------------------------- /forkit/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import Signal 2 | 3 | pre_reset = Signal(providing_args=('reference', 'instance', 'config')) 4 | post_reset = Signal(providing_args=('reference', 'instance')) 5 | 6 | pre_fork = Signal(providing_args=('reference', 'instance', 'config')) 7 | post_fork = Signal(providing_args=('reference', 'instance')) 8 | 9 | pre_diff = Signal(providing_args=('reference', 'instance', 'config')) 10 | post_diff = Signal(providing_args=('reference', 'instance', 'diff')) 11 | 12 | pre_commit = Signal(providing_args=('reference', 'instance')) 13 | post_commit = Signal(providing_args=('reference', 'instance')) 14 | -------------------------------------------------------------------------------- /forkit/__init__.py: -------------------------------------------------------------------------------- 1 | __version_info__ = { 2 | 'major': 0, 3 | 'minor': 9, 4 | 'micro': 5, 5 | 'releaselevel': 'final', 6 | 'serial': 1 7 | } 8 | 9 | def get_version(short=False): 10 | assert __version_info__['releaselevel'] in ('alpha', 'beta', 'final') 11 | vers = ["%(major)i.%(minor)i" % __version_info__, ] 12 | if __version_info__['micro']: 13 | vers.append(".%(micro)i" % __version_info__) 14 | if __version_info__['releaselevel'] != 'final' and not short: 15 | vers.append('%s%i' % (__version_info__['releaselevel'][0], __version_info__['serial'])) 16 | return ''.join(vers) 17 | 18 | __version__ = get_version() 19 | -------------------------------------------------------------------------------- /forkit/tests/receivers.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import receiver 2 | from forkit import signals 3 | 4 | @receiver(signals.pre_reset) 5 | def debug_pre_reset(instance, **kwargs): 6 | print repr(instance), 'pre-reset' 7 | 8 | @receiver(signals.post_reset) 9 | def debug_post_reset(instance, **kwargs): 10 | print repr(instance), 'post-reset' 11 | 12 | @receiver(signals.pre_commit) 13 | def debug_pre_commit(instance, **kwargs): 14 | print repr(instance), 'pre-commit' 15 | 16 | @receiver(signals.post_commit) 17 | def debug_post_commit(instance, **kwargs): 18 | print repr(instance), 'post-commit' 19 | 20 | @receiver(signals.pre_fork) 21 | def debug_pre_fork(instance, **kwargs): 22 | print repr(instance), 'pre-fork' 23 | 24 | @receiver(signals.post_fork) 25 | def debug_post_fork(instance, **kwargs): 26 | print repr(instance), 'post-fork' 27 | 28 | @receiver(signals.pre_diff) 29 | def debug_pre_diff(instance, **kwargs): 30 | print repr(instance), 'pre-diff' 31 | 32 | @receiver(signals.post_diff) 33 | def debug_post_diff(instance, **kwargs): 34 | print repr(instance), 'post-diff' 35 | -------------------------------------------------------------------------------- /forkit/tests/coverage_test.py: -------------------------------------------------------------------------------- 1 | import os 2 | import coverage 3 | import colorize 4 | 5 | from django.conf import settings 6 | from django.test.simple import DjangoTestSuiteRunner 7 | 8 | class CoverageTestRunner(DjangoTestSuiteRunner): 9 | 10 | def run_tests(self, test_labels, verbosity=1, interactive=True, extra_tests=[]): 11 | coveragemodules = getattr(settings, 'COVERAGE_MODULES', []) 12 | 13 | if coveragemodules: 14 | coverage.start() 15 | 16 | self.setup_test_environment() 17 | 18 | suite = self.build_suite(test_labels, extra_tests) 19 | old_config = self.setup_databases() 20 | result = self.run_suite(suite) 21 | 22 | if coveragemodules: 23 | coverage.stop() 24 | coveragedir = getattr(settings, 'COVERAGE_DIR', './build/coverage') 25 | if not os.path.exists(coveragedir): 26 | os.makedirs(coveragedir) 27 | 28 | modules = [] 29 | for module_string in coveragemodules: 30 | module = __import__(module_string, globals(), locals(), [""]) 31 | modules.append(module) 32 | f,s,m,mf = coverage.analysis(module) 33 | fp = file(os.path.join(coveragedir, module_string + ".html"), "wb") 34 | colorize.colorize_file(f, outstream=fp, not_covered=mf) 35 | fp.close() 36 | coverage.report(modules, show_missing=0) 37 | coverage.erase() 38 | 39 | self.teardown_databases(old_config) 40 | self.teardown_test_environment() 41 | 42 | return len(result.failures) + len(result.errors) 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) The Children's Hospital of Philadelphia and individual contributors. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions 6 | are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, 9 | this list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the The Children's Hospital of Philadelphia nor the 16 | names of its contributors may be used to endorse or promote products 17 | derived from this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 22 | ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 23 | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 24 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 25 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 26 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 27 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 28 | ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 29 | POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /forkit/tests/fixtures/test_data.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "pk": 1, 4 | "model": "tests.author", 5 | "fields": { 6 | "first_name": "Byron", 7 | "last_name": "Ruth" 8 | } 9 | }, 10 | { 11 | "pk": 2, 12 | "model": "tests.author", 13 | "fields": { 14 | "first_name": "Pat", 15 | "last_name": "Henning" 16 | } 17 | }, 18 | { 19 | "pk": 1, 20 | "model": "tests.tag", 21 | "fields": { 22 | "name": "python" 23 | } 24 | }, 25 | { 26 | "pk": 2, 27 | "model": "tests.tag", 28 | "fields": { 29 | "name": "tip" 30 | } 31 | }, 32 | { 33 | "pk": 3, 34 | "model": "tests.tag", 35 | "fields": { 36 | "name": "descriptor" 37 | } 38 | }, 39 | { 40 | "pk": 1, 41 | "model": "tests.blog", 42 | "fields": { 43 | "name": "devel.io", 44 | "author": 1 45 | } 46 | }, 47 | { 48 | "pk": 1, 49 | "model": "tests.post", 50 | "fields": { 51 | "blog": 1, 52 | "authors": [1, 2], 53 | "title": "Django Tip: Descriptors", 54 | "tags": [1, 2, 3] 55 | } 56 | }, 57 | { 58 | "pk": 1, 59 | "model": "tests.a", 60 | "fields": { 61 | "title": "1" 62 | } 63 | }, 64 | { 65 | "pk": 1, 66 | "model": "tests.b", 67 | "fields": { 68 | "title": "2" 69 | } 70 | }, 71 | { 72 | "pk": 1, 73 | "model": "tests.c", 74 | "fields": { 75 | "title": "3", 76 | "a": 1, 77 | "b": 1 78 | } 79 | } 80 | ] 81 | -------------------------------------------------------------------------------- /forkit/tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from forkit.models import ForkableModel 3 | 4 | class Tag(ForkableModel): 5 | name = models.CharField(max_length=30) 6 | 7 | def __unicode__(self): 8 | return u'{0}'.format(self.name) 9 | 10 | class Author(ForkableModel): 11 | first_name = models.CharField(max_length=30) 12 | last_name = models.CharField(max_length=30) 13 | 14 | def __unicode__(self): 15 | return u'{0} {1}'.format(self.first_name, self.last_name) 16 | 17 | 18 | class Blog(ForkableModel): 19 | name = models.CharField(max_length=50) 20 | author = models.OneToOneField(Author) 21 | 22 | def __unicode__(self): 23 | return u'{0}'.format(self.name) 24 | 25 | 26 | class Post(ForkableModel): 27 | title = models.CharField(max_length=50) 28 | # intentionally left off the related_name attr 29 | blog = models.ForeignKey(Blog) 30 | authors = models.ManyToManyField(Author, related_name='posts') 31 | # intentionally left off the related_name attr 32 | tags = models.ManyToManyField(Tag) 33 | 34 | def __unicode__(self): 35 | return u'{0}'.format(self.title) 36 | 37 | 38 | class A(ForkableModel): 39 | title = models.CharField(max_length=50) 40 | d = models.ForeignKey('D', null=True) 41 | 42 | def __unicode__(self): 43 | return u'{0}'.format(self.title) 44 | 45 | 46 | class B(ForkableModel): 47 | title = models.CharField(max_length=50) 48 | 49 | def __unicode__(self): 50 | return u'{0}'.format(self.title) 51 | 52 | 53 | class C(ForkableModel): 54 | title = models.CharField(max_length=50) 55 | a = models.ForeignKey(A, null=True) 56 | b = models.ForeignKey(B, null=True) 57 | 58 | def __unicode__(self): 59 | return u'{0}'.format(self.title) 60 | 61 | 62 | class D(ForkableModel): 63 | title = models.CharField(max_length=50) 64 | 65 | def __unicode__(self): 66 | return u'{0}'.format(self.title) 67 | 68 | -------------------------------------------------------------------------------- /forkit/tests/signals.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from forkit import utils, signals 3 | from forkit.tests.models import Author, Post, Blog, Tag 4 | 5 | __all__ = ('SignalsTestCase',) 6 | 7 | # receivers 8 | def author_config(sender, config, **kwargs): 9 | config['fields'] = ['first_name', 'posts'] 10 | 11 | def post_config(sender, config, **kwargs): 12 | config['fields'] = ['title'] 13 | 14 | class SignalsTestCase(TestCase): 15 | fixtures = ['test_data.json'] 16 | 17 | def setUp(self): 18 | self.author = Author.objects.get(pk=1) 19 | self.post = Post.objects.get(pk=1) 20 | self.blog = Blog.objects.get(pk=1) 21 | self.tag = Tag.objects.get(pk=1) 22 | 23 | def test_shallow_signal(self): 24 | signals.pre_fork.connect(author_config, sender=Author) 25 | 26 | fork = self.author.fork() 27 | self.assertEqual(self.author.diff(fork), { 28 | 'last_name': '' 29 | }); 30 | 31 | signals.pre_fork.disconnect(author_config) 32 | 33 | def test_deep_signal(self): 34 | # before signal is connected.. complete deep fork 35 | fork = self.author.fork(commit=False, deep=True) 36 | 37 | post0 = utils._get_field_value(fork, 'posts')[0][0] 38 | self.assertEqual(post0.title, 'Django Tip: Descriptors') 39 | 40 | blog0 = utils._get_field_value(post0, 'blog')[0] 41 | self.assertTrue(isinstance(blog0, Blog)) 42 | 43 | # connect the post signal to limit the fields.. 44 | signals.pre_fork.connect(post_config, sender=Post) 45 | 46 | fork = self.author.fork(commit=False, deep=True) 47 | 48 | post0 = utils._get_field_value(fork, 'posts')[0][0] 49 | self.assertEqual(post0.title, 'Django Tip: Descriptors') 50 | 51 | blog0 = utils._get_field_value(post0, 'blog')[0] 52 | # odd usage of _get_field_value, but it works.. 53 | self.assertEqual(blog0, None) 54 | 55 | signals.pre_fork.disconnect(post_config) 56 | 57 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | 2011-10-10 Byron Ruth 2 | 3 | * Arbitrary Signal Keyword Arguments. Each utility function can take any 4 | additional keyword arguments that will be passed to each signal receiver 5 | called for the entirety of the operation. 6 | 7 | 2011-10-07 Byron Ruth 8 | 9 | * Deep Reset. Improved deep reset capability which does not incorrectly 10 | fork related objects. In this implementation, only directly related 11 | objects are traversed. 12 | 13 | 2011-10-05 Byron Ruth 14 | 15 | * Pre and Post Signals. Signals have been implemented for customizing the 16 | behavior of a `fork', `reset', `diff', and `commit' outside of the normal 17 | use of the API. The sender of the each signal is the model class of the 18 | model instance being manipulated. 19 | 20 | The `fork', `reset', and `diff' pre- signals pass the `reference', 21 | `instance', and `config' to the receiver. The `config' is a `dict' which 22 | can be updated in-place for greater flexibility. 23 | 24 | 2011-10-04 Byron Ruth 25 | 26 | * API Refactor. Public methods including `fork', `reset', `diff', and 27 | `commit' have been defined as standalone functions. This refactor 28 | emphasizes greater coverage of applicability for all existing models 29 | rather than only those that inherit from `ForkableModel'. `ForkableModel' 30 | still contains the public methods for backwards compatibility and 31 | convenience. 32 | 33 | 2011-09-09 Byron Ruth 34 | 35 | * Reset Method. Added `reset' method which requires a model instance 36 | `target' that enables "resetting" an existing object rather than creating 37 | a new object per `fork'. 38 | 39 | 2011-09-09 Byron Ruth 40 | 41 | * Forking. Implemented an abstract model class `ForkableModel' which 42 | supports shallow and deep forking model instances. 43 | 44 | * Deferred Commits. All relationships are deferred from being saved until 45 | the whole hierarchy has been traversed during forking. Forks are not 46 | committed by default to ensure integrity errors are not forced. 47 | 48 | * Diffs. `ForkableModel' also implements a `diff' method for instances 49 | that can be used to compare data values against another object of the same 50 | type. 51 | 52 | * Limitations. `through' models for many-to-many fields are not yet 53 | supported. Deep diffs are not yet supported. 54 | -------------------------------------------------------------------------------- /forkit/tests/reset.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from forkit.tests.models import A, B, C, D 3 | 4 | __all__ = ('ResetModelObjectTestCase',) 5 | 6 | class ResetModelObjectTestCase(TestCase): 7 | def test_shallow_reset(self): 8 | d1 = D(title='d1') 9 | a1 = A(title='a1', d=d1) 10 | a1.save() 11 | b1 = B(title='b1') 12 | b1.save() 13 | c1 = C(title='c1', a=a1, b=b1) 14 | c1.save() 15 | 16 | c2 = C(title='c2') 17 | 18 | c1.reset(c2) 19 | 20 | # shallow resets will add direct relationships if one does not 21 | # already exist, but will not traverse them 22 | self.assertEqual(c1.title, c2.title) 23 | self.assertEqual(c2.a, a1) 24 | self.assertEqual(c2.b, b1) 25 | 26 | # give c2 a reference to a2.. 27 | a2 = A(title='a2') 28 | a2.save() 29 | c2.a = a2 30 | c2.title = 'c2' 31 | c2.save() 32 | 33 | c1.reset(c2) 34 | 35 | # now that c2 has a2, it does not get the a1 reference, nor 36 | # does a2's local attributes become reset (only deep) 37 | self.assertEqual(c1.title, c2.title) 38 | self.assertEqual(c2.a, a2) 39 | self.assertEqual(a2.d, None) 40 | self.assertEqual(a2.title, 'a2') 41 | self.assertEqual(c2.b, b1) 42 | 43 | 44 | def test_deep_reset(self): 45 | d1 = D(title='d1') 46 | a1 = A(title='a1', d=d1) 47 | a1.save() 48 | b1 = B(title='b1') 49 | b1.save() 50 | c1 = C(title='c1', a=a1, b=b1) 51 | c1.save() 52 | 53 | c2 = C(title='c2') 54 | 55 | c1.reset(c2, deep=True) 56 | 57 | # shallow resets will add direct relationships if one does not 58 | # already exist 59 | self.assertEqual(c1.title, c2.title) 60 | self.assertEqual(c2.a, a1) 61 | self.assertEqual(c2.b, b1) 62 | 63 | # give c2 a reference to a2.. 64 | a2 = A(title='a2') 65 | a2.save() 66 | c2.a = a2 67 | c2.title = 'c2' 68 | c2.save() 69 | 70 | c1.reset(c2, deep=True) 71 | 72 | # now that c2 has a2, it does not get the a1 reference. 73 | self.assertEqual(c1.title, c2.title) 74 | self.assertEqual(c2.a, a2) 75 | self.assertEqual(c2.b, b1) 76 | 77 | # a2 gets reset relative to a1 78 | self.assertEqual(a2.title, a1.title) 79 | self.assertEqual(a2.d, d1) 80 | -------------------------------------------------------------------------------- /forkit/diff.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from forkit import utils, signals 3 | 4 | def _diff_field(reference, instance, accessor, deep, **kwargs): 5 | "Returns the field's value of ``instance`` if different form ``reference``." 6 | val1, field, direct, m2m = utils._get_field_value(reference, accessor) 7 | val2 = utils._get_field_value(instance, accessor)[0] 8 | 9 | # get the diff for m2m or reverse foreign keys 10 | if m2m or not direct and not isinstance(field, models.OneToOneField): 11 | if _diff_queryset(reference, val1, val2) is not None: 12 | return {accessor: list(val2)} 13 | # direct foreign keys and one-to-one 14 | elif deep and (isinstance(field, models.ForeignKey) or isinstance(field, models.OneToOneField)): 15 | if val1 and val2: 16 | diff = diff_model_object(val1, val2, **kwargs) 17 | if diff: 18 | return {accessor: diff} 19 | elif val1 != val2: 20 | return {accessor: val2} 21 | return {} 22 | 23 | def _diff_queryset(reference, qs1, qs2): 24 | "Compares two QuerySets by their primary keys." 25 | # if they point to a related manager, perform the lookup and compare 26 | # the primary keys 27 | if qs1 and qs2: 28 | pks1 = qs1.values_list('pk', flat=True) 29 | pks2 = qs2.values_list('pk', flat=True) 30 | if set(pks1) != set(pks2): 31 | return qs2 32 | # if they are different, check to see if either one is empty 33 | elif qs1: 34 | if qs1.count(): return qs2 35 | elif qs2: 36 | if qs2.count(): return qs2 37 | 38 | def _diff(reference, instance, fields=None, exclude=('pk',), deep=False, **kwargs): 39 | if not fields: 40 | fields = utils._default_model_fields(reference, exclude, deep=deep) 41 | 42 | diff = {} 43 | for accessor in fields: 44 | diff.update(_diff_field(reference, instance, accessor, deep=deep, **kwargs)) 45 | 46 | return diff 47 | 48 | def diff_model_object(reference, instance, **kwargs): 49 | """Creates a diff between two model objects of the same type relative to 50 | ``reference``. If ``fields`` is not supplied, all local fields and many-to-many 51 | fields will be included. The ``pk`` field is excluded by default. 52 | """ 53 | # pre-signal 54 | signals.pre_diff.send(sender=reference.__class__, reference=reference, 55 | instance=instance, config=kwargs, **kwargs) 56 | diff = _diff(reference, instance, **kwargs) 57 | # post-signal 58 | signals.post_diff.send(sender=reference.__class__, reference=reference, 59 | instance=instance, diff=diff, **kwargs) 60 | return diff 61 | -------------------------------------------------------------------------------- /forkit/tests/diff.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from forkit.tests.models import Author, Post, Blog, Tag, C 3 | 4 | __all__ = ('DiffModelObjectTestCase',) 5 | 6 | class DiffModelObjectTestCase(TestCase): 7 | fixtures = ['test_data.json'] 8 | 9 | def setUp(self): 10 | self.author = Author.objects.get(pk=1) 11 | self.post = Post.objects.get(pk=1) 12 | self.blog = Blog.objects.get(pk=1) 13 | self.tag = Tag.objects.get(pk=1) 14 | 15 | def test_empty_shallow_diff(self): 16 | diff = self.author.diff(Author()) 17 | self.assertEqual(diff, { 18 | 'first_name': '', 19 | 'last_name': '', 20 | 'posts': [], 21 | }) 22 | 23 | diff = self.blog.diff(Blog()) 24 | self.assertEqual(diff, { 25 | 'name': '', 26 | 'author': None, 27 | }) 28 | 29 | diff = self.post.diff(Post()) 30 | self.assertEqual(diff, { 31 | 'blog': None, 32 | 'authors': [], 33 | 'tags': [], 34 | 'title': '', 35 | }) 36 | 37 | diff = self.tag.diff(Tag()) 38 | self.assertEqual(diff, { 39 | 'name': '', 40 | 'post_set': [], 41 | }) 42 | 43 | def test_fork_shallow_diff(self): 44 | # even without the commit, the diff is clean. related objects are 45 | # compared against the _related dict 46 | 47 | fork = self.author.fork(commit=False) 48 | diff = fork.diff(self.author) 49 | self.assertEqual(diff, {}) 50 | 51 | fork = self.post.fork(commit=False) 52 | diff = fork.diff(self.post) 53 | self.assertEqual(diff, {}) 54 | 55 | # since Author is a OneToOneField and this is not a deep fork, it 56 | # still does not have a value 57 | fork = self.blog.fork(commit=False) 58 | diff = fork.diff(self.blog) 59 | self.assertEqual(diff, { 60 | 'author': self.author 61 | }) 62 | 63 | diff = self.blog.diff(fork) 64 | self.assertEqual(diff, { 65 | 'author': None 66 | }) 67 | 68 | fork = self.tag.fork(commit=False) 69 | diff = self.tag.diff(fork) 70 | self.assertEqual(diff, {}) 71 | 72 | def test_deep_diff(self): 73 | # only simple data models are currently supported 74 | c = C.objects.get(pk=1) 75 | 76 | # need to commit, since lists are not yet handled.. 77 | fork = c.fork(commit=True, deep=True) 78 | diff = c.diff(fork, deep=True) 79 | self.assertEqual(diff, {}) 80 | 81 | fork.b.title = 'foobar' 82 | self.assertEqual(c.diff(fork, deep=True), { 83 | 'b': { 84 | 'title': 'foobar', 85 | } 86 | }) 87 | -------------------------------------------------------------------------------- /forkit/commit.py: -------------------------------------------------------------------------------- 1 | from django.db import models, transaction 2 | from forkit import utils, signals 3 | 4 | def _commit_direct(instance, memo, **kwargs): 5 | """Recursively set all direct related object references to the 6 | instance object. Each downstream related object is saved before 7 | being set. 8 | """ 9 | # get and clear to prevent infinite recursion 10 | relations = instance._commits.direct.items() 11 | instance._commits.direct = {} 12 | 13 | for accessor, value in relations: 14 | _memoize_commit(value, memo=memo, **kwargs) 15 | # save the object to get a primary key 16 | setattr(instance, accessor, value) 17 | 18 | def _commit_related(instance, memo, stack, **kwargs): 19 | relations = instance._commits.related.items() 20 | instance._commits.related = {} 21 | 22 | for accessor, value in relations: 23 | # execute the commit direct cycle for these related objects, 24 | if isinstance(value, utils.DeferredCommit): 25 | value = value.value 26 | if type(value) is list: 27 | stack.extend(value) 28 | else: 29 | stack.append(value) 30 | else: 31 | if type(value) is list: 32 | map(lambda rel: _memoize_commit(rel, memo=memo, **kwargs), value) 33 | elif isinstance(value, models.Model): 34 | _memoize_commit(value, memo=memo, **kwargs) 35 | 36 | setattr(instance, accessor, value) 37 | 38 | def _memoize_commit(instance, **kwargs): 39 | if not hasattr(instance, '_commits'): 40 | return instance 41 | 42 | reference = instance._commits.reference 43 | 44 | root = False 45 | memo = kwargs.pop('memo', None) 46 | stack = kwargs.pop('stack', []) 47 | 48 | # for every call, keep track of the reference and the instance being 49 | # acted on. this is used for recursive calls to related objects. this 50 | # ensures relationships that follow back up the tree are caught and are 51 | # merely referenced rather than traversed again. 52 | if memo is None: 53 | root = True 54 | memo = utils.Memo() 55 | elif memo.has(reference): 56 | return memo.get(reference) 57 | 58 | memo.add(reference, instance) 59 | 60 | # pre-signal 61 | signals.pre_commit.send(sender=reference.__class__, reference=reference, 62 | instance=instance, **kwargs) 63 | 64 | # commit all dependencies first, save it, then travese dependents 65 | _commit_direct(instance, memo=memo, **kwargs) 66 | instance.save() 67 | _commit_related(instance, memo=memo, stack=stack, **kwargs) 68 | 69 | if root: 70 | for value in iter(stack): 71 | _memoize_commit(value, memo=memo, stack=[], **kwargs) 72 | 73 | # post-signal 74 | signals.post_commit.send(sender=reference.__class__, reference=reference, 75 | instance=instance, **kwargs) 76 | 77 | return instance 78 | 79 | @transaction.commit_on_success 80 | def commit_model_object(instance, **kwargs): 81 | "Recursively commits direct and related objects." 82 | return _memoize_commit(instance, **kwargs) 83 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from distutils.core import setup 2 | from distutils.command.install_data import install_data 3 | from distutils.command.install import INSTALL_SCHEMES 4 | 5 | import os 6 | import sys 7 | 8 | BASE_PACKAGE = 'forkit' 9 | 10 | class osx_install_data(install_data): 11 | # On MacOS, the platform-specific lib dir is /System/Library/Framework/Python/.../ 12 | # which is wrong. Python 2.5 supplied with MacOS 10.5 has an Apple-specific fix 13 | # for this in distutils.command.install_data#306. It fixes install_lib but not 14 | # install_data, which is why we roll our own install_data class. 15 | 16 | def finalize_options(self): 17 | # By the time finalize_options is called, install.install_lib is set to the 18 | # fixed directory, so we set the installdir to install_lib. The 19 | # install_data class uses ('install_data', 'install_dir') instead. 20 | self.set_undefined_options('install', ('install_lib', 'install_dir')) 21 | install_data.finalize_options(self) 22 | 23 | if sys.platform == "darwin": 24 | cmdclasses = {'install_data': osx_install_data} 25 | else: 26 | cmdclasses = {'install_data': install_data} 27 | 28 | def fullsplit(path, result=None): 29 | """ 30 | Split a pathname into components (the opposite of os.path.join) in a 31 | platform-neutral way. 32 | """ 33 | if result is None: 34 | result = [] 35 | head, tail = os.path.split(path) 36 | if head == '': 37 | return [tail] + result 38 | if head == path: 39 | return result 40 | return fullsplit(head, [tail] + result) 41 | 42 | # Tell distutils to put the data_files in platform-specific installation 43 | # locations. See here for an explanation: 44 | # http://groups.google.com/group/comp.lang.python/browse_thread/thread/35ec7b2fed36eaec/2105ee4d9e8042cb 45 | for scheme in INSTALL_SCHEMES.values(): 46 | scheme['data'] = scheme['purelib'] 47 | 48 | # Compile the list of packages available, because distutils doesn't have 49 | # an easy way to do this. 50 | packages, data_files = [], [] 51 | root_dir = os.path.dirname(__file__) 52 | if root_dir != '': 53 | os.chdir(root_dir) 54 | 55 | for dirpath, dirnames, filenames in os.walk(BASE_PACKAGE): 56 | # Ignore dirnames that start with '.' 57 | for i, dirname in enumerate(dirnames): 58 | if dirname.startswith('.'): 59 | del dirnames[i] 60 | elif dirname in ('tests', 'fixtures'): 61 | del dirnames[i] 62 | if '__init__.py' in filenames: 63 | packages.append('.'.join(fullsplit(dirpath))) 64 | elif filenames: 65 | data_files.append([dirpath, [os.path.join(dirpath, f) for f in filenames]]) 66 | 67 | # Small hack for working with bdist_wininst. 68 | # See http://mail.python.org/pipermail/distutils-sig/2004-August/004134.html 69 | if len(sys.argv) > 1 and sys.argv[1] == 'bdist_wininst': 70 | for file_info in data_files: 71 | file_info[0] = '\\PURELIB\\%s' % file_info[0] 72 | 73 | version = __import__(BASE_PACKAGE).get_version() 74 | 75 | setup( 76 | version = version, 77 | name = 'django-forkit', 78 | author = 'Byron Ruth', 79 | author_email = 'b@devel.io', 80 | description = 'Utility functions for forking, resetting ' \ 81 | 'and diffing model objects', 82 | license = 'BSD', 83 | keywords = 'fork deepcopy model abstract diff', 84 | 85 | packages = packages, 86 | cmdclass = cmdclasses, 87 | 88 | data_files = data_files, 89 | classifiers = [ 90 | 'Development Status :: 4 - Beta', 91 | 'Framework :: Django', 92 | 'Intended Audience :: Developers', 93 | 'License :: OSI Approved :: BSD License', 94 | 'Operating System :: OS Independent', 95 | 'Programming Language :: Python', 96 | 'Topic :: Internet :: WWW/HTTP', 97 | ], 98 | ) 99 | -------------------------------------------------------------------------------- /forkit/tests/utils.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from forkit import utils, diff 3 | from forkit.tests.models import Author, Post, Blog, Tag 4 | 5 | __all__ = ('UtilsTestCase',) 6 | 7 | class UtilsTestCase(TestCase): 8 | fixtures = ['test_data.json'] 9 | 10 | def setUp(self): 11 | self.author = Author.objects.get(pk=1) 12 | self.post = Post.objects.get(pk=1) 13 | self.blog = Blog.objects.get(pk=1) 14 | self.tag = Tag.objects.get(pk=1) 15 | 16 | def test_accessor_cache(self): 17 | utils._get_field_by_accessor(self.author, 'posts') 18 | utils._get_field_by_accessor(self.author, 'blog') 19 | 20 | utils._get_field_by_accessor(self.post, 'authors') 21 | utils._get_field_by_accessor(self.post, 'blog') 22 | utils._get_field_by_accessor(self.post, 'tags') 23 | 24 | utils._get_field_by_accessor(self.blog, 'author') 25 | # intentionally left off a related_name 26 | utils._get_field_by_accessor(self.blog, 'post_set') 27 | # the memo was created for the ``post_set`` accessor 28 | self.assertEqual(self.blog._meta.related_objects_by_accessor.keys(), ['post_set']) 29 | 30 | # reverse many-to-many without a related_name can also be looked up by 31 | # their model name 32 | utils._get_field_by_accessor(self.tag, 'post') 33 | 34 | def test_field_value(self): 35 | self.assertEqual(utils._get_field_value(self.author, 'first_name')[0], 'Byron') 36 | # returns a queryset, compare the querysets 37 | author_posts = utils._get_field_value(self.author, 'posts')[0] 38 | self.assertEqual(diff._diff_queryset(self.author, author_posts, Post.objects.all()), None) 39 | # one-to-ones are simple, the instance if returned directly 40 | self.assertEqual(utils._get_field_value(self.author, 'blog')[0], self.blog) 41 | 42 | # direct foreign key, same as one-to-one 43 | self.assertEqual(utils._get_field_value(self.post, 'blog')[0], self.blog) 44 | # direct many-to-many, behaves the same as reverse foreign keys 45 | post_authors = utils._get_field_value(self.post, 'authors')[0] 46 | self.assertEqual(diff._diff_queryset(self.post, post_authors, Author.objects.all()), None) 47 | # direct many-to-many, behaves the same as reverse foreign keys 48 | post_tags = utils._get_field_value(self.post, 'tags')[0] 49 | self.assertEqual(diff._diff_queryset(self.post, post_tags, Tag.objects.all()), None) 50 | 51 | self.assertEqual(utils._get_field_value(self.blog, 'author')[0], self.author) 52 | blog_posts = utils._get_field_value(self.blog, 'post_set')[0] 53 | self.assertEqual(diff._diff_queryset(self.blog, blog_posts, Post.objects.all()), None) 54 | 55 | tag_posts = utils._get_field_value(self.tag, 'post_set')[0] 56 | self.assertEqual(diff._diff_queryset(self.blog, tag_posts, Post.objects.all()), None) 57 | 58 | def test_shallow_default_fields(self): 59 | author = Author() 60 | post = Post() 61 | blog = Blog() 62 | tag = Tag() 63 | 64 | self.assertEqual(utils._default_model_fields(author), 65 | set(['first_name', 'last_name', 'posts'])) 66 | 67 | self.assertEqual(utils._default_model_fields(post), 68 | set(['blog', 'authors', 'tags', 'title'])) 69 | 70 | self.assertEqual(utils._default_model_fields(blog), 71 | set(['name', 'author'])) 72 | 73 | self.assertEqual(utils._default_model_fields(tag), 74 | set(['name', 'post_set'])) 75 | 76 | def test_deep_default_fields(self): 77 | author = Author() 78 | post = Post() 79 | blog = Blog() 80 | tag = Tag() 81 | 82 | self.assertEqual(utils._default_model_fields(author, deep=True), 83 | set(['first_name', 'last_name', 'posts', 'blog'])) 84 | 85 | self.assertEqual(utils._default_model_fields(post, deep=True), 86 | set(['blog', 'authors', 'tags', 'title'])) 87 | 88 | self.assertEqual(utils._default_model_fields(blog, deep=True), 89 | set(['name', 'author', 'post_set'])) 90 | 91 | self.assertEqual(utils._default_model_fields(tag, deep=True), 92 | set(['name', 'post_set'])) 93 | 94 | -------------------------------------------------------------------------------- /forkit/reset.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | from django.db import models 3 | from forkit import utils, signals 4 | from forkit.commit import commit_model_object 5 | 6 | def _reset_one2one(instance, refvalue, field, direct, accessor, deep, **kwargs): 7 | value = utils._get_field_value(instance, accessor)[0] 8 | if refvalue and value and deep: 9 | _memoize_reset(refvalue, value, deep=deep, **kwargs) 10 | instance._commits.defer(accessor, value, direct=direct) 11 | 12 | def _reset_foreignkey(instance, refvalue, field, direct, accessor, deep, **kwargs): 13 | value = utils._get_field_value(instance, accessor)[0] 14 | if refvalue and value and deep: 15 | _memoize_reset(refvalue, value, deep=deep, **kwargs) 16 | # for shallow or when value is None, use the reference value 17 | elif not value: 18 | value = refvalue 19 | 20 | instance._commits.defer(accessor, value, direct=direct) 21 | 22 | def _reset_field(reference, instance, accessor, **kwargs): 23 | """Creates a copy of the reference value for the defined ``accessor`` 24 | (field). For deep forks, each related object is related objects must 25 | be created first prior to being recursed. 26 | """ 27 | value, field, direct, m2m = utils._get_field_value(reference, accessor) 28 | 29 | # explicitly block reverse and m2m relationships.. 30 | if not direct or m2m: 31 | return 32 | 33 | kwargs['commit'] = False 34 | 35 | if isinstance(field, models.OneToOneField): 36 | return _reset_one2one(instance, value, field, direct, 37 | accessor, **kwargs) 38 | 39 | if isinstance(field, models.ForeignKey): 40 | return _reset_foreignkey(instance, value, field, direct, 41 | accessor, **kwargs) 42 | 43 | # non-relational field, perform a deepcopy to ensure no mutable nonsense 44 | setattr(instance, accessor, deepcopy(value)) 45 | 46 | def _memoize_reset(reference, instance, **kwargs): 47 | "Resets the specified instance relative to ``reference``" 48 | # popped so it does not get included in the config for the signal 49 | memo = kwargs.pop('memo', None) 50 | 51 | # for every call, keep track of the reference and the object (fork). 52 | # this is used for recursive calls to related objects. this ensures 53 | # relationships that follow back up the tree are caught and are merely 54 | # referenced rather than traversed again. 55 | if memo is None: 56 | memo = utils.Memo() 57 | elif memo.has(reference): 58 | return memo.get(reference) 59 | 60 | if not isinstance(instance, reference.__class__): 61 | raise TypeError('The instance supplied must be of the same type as the reference') 62 | 63 | instance._commits = utils.Commits(reference) 64 | memo.add(reference, instance) 65 | 66 | # default configuration 67 | config = { 68 | 'fields': None, 69 | 'exclude': ['pk'], 70 | 'deep': False, 71 | 'commit': True, 72 | } 73 | 74 | # update with user-defined 75 | config.update(kwargs) 76 | 77 | # pre-signal 78 | signals.pre_reset.send(sender=reference.__class__, reference=reference, 79 | instance=instance, config=config, **kwargs) 80 | 81 | fields = config['fields'] 82 | exclude = config['exclude'] 83 | deep = config['deep'] 84 | commit = config['commit'] 85 | 86 | # no fields are defined, so get the default ones for shallow or deep 87 | if not fields: 88 | fields = utils._default_model_fields(reference, exclude=exclude, deep=deep) 89 | 90 | kwargs.update({'deep': deep}) 91 | 92 | # iterate over each field and fork it!. nested calls will not commit, 93 | # until the recursion has finished 94 | for accessor in fields: 95 | _reset_field(reference, instance, accessor, **kwargs) 96 | 97 | # post-signal 98 | signals.post_reset.send(sender=reference.__class__, reference=reference, 99 | instance=instance, **kwargs) 100 | 101 | if commit: 102 | commit_model_object(instance) 103 | 104 | return instance 105 | 106 | def reset_model_object(reference, instance, **kwargs): 107 | "Resets the ``instance`` object relative to ``reference``'s state." 108 | return _memoize_reset(reference, instance, **kwargs) 109 | -------------------------------------------------------------------------------- /forkit/tests/fork.py: -------------------------------------------------------------------------------- 1 | from django.db import IntegrityError 2 | from django.test import TestCase 3 | from forkit.tests.models import Author, Post, Blog, Tag 4 | 5 | __all__ = ('ForkModelObjectTestCase',) 6 | 7 | class ForkModelObjectTestCase(TestCase): 8 | fixtures = ['test_data.json'] 9 | 10 | def setUp(self): 11 | self.author = Author.objects.get(pk=1) 12 | self.post = Post.objects.get(pk=1) 13 | self.blog = Blog.objects.get(pk=1) 14 | self.tag = Tag.objects.get(pk=1) 15 | 16 | def test_shallow_fork(self): 17 | # Author 18 | 19 | fork = self.author.fork() 20 | 21 | self.assertEqual(fork.pk, 3) 22 | self.assertEqual(self.author.posts.through.objects.count(), 3) 23 | 24 | fork2 = self.author.fork(commit=False) 25 | 26 | self.assertEqual(fork2.pk, None) 27 | self.assertEqual(fork2._commits.related.keys(), ['posts']) 28 | 29 | fork2.commit() 30 | self.assertEqual(fork2.pk, 4) 31 | self.assertEqual(self.author.posts.through.objects.count(), 4) 32 | self.assertEqual(fork2._commits.related, {}) 33 | 34 | # Post 35 | 36 | fork = self.post.fork() 37 | self.assertEqual(fork.pk, 2) 38 | # 2 self.posts X 4 authors 39 | self.assertEqual(self.post.authors.through.objects.count(), 8) 40 | self.assertEqual(self.post.tags.through.objects.count(), 6) 41 | self.assertEqual(Blog.objects.count(), 1) 42 | 43 | fork2 = self.post.fork(commit=False) 44 | self.assertEqual(fork2.pk, None) 45 | 46 | fork2.commit() 47 | # 3 posts X 4 authors 48 | self.assertEqual(self.post.authors.through.objects.count(), 12) 49 | self.assertEqual(self.post.tags.through.objects.count(), 9) 50 | self.assertEqual(Blog.objects.count(), 1) 51 | 52 | # Blog 53 | 54 | # since this gets auto-committed, and shallow forks do not include 55 | # direct relation forking, no author has been set. 56 | self.assertRaises(IntegrityError, self.blog.fork) 57 | fork = self.blog.fork(commit=False) 58 | 59 | fork_author = Author() 60 | fork_author.save() 61 | fork.author = fork_author 62 | fork.commit() 63 | 64 | self.assertEqual(fork.pk, 2) 65 | 66 | # test fork when one-to-one is not yet 67 | blog = Blog() 68 | fork = blog.fork(commit=False) 69 | self.assertEqual(fork.diff(blog), {}) 70 | 71 | # Tag 72 | 73 | fork = self.tag.fork() 74 | self.assertEqual(fork.pk, 4) 75 | # 3 posts X 4 tags 76 | self.assertEqual(fork.post_set.through.objects.count(), 12) 77 | 78 | def test_deep_fork(self): 79 | # Author 80 | 81 | fork = self.author.fork(deep=True) 82 | 83 | self.assertEqual(fork.pk, 3) 84 | 85 | # new counts 86 | self.assertEqual(Author.objects.count(), 4) 87 | self.assertEqual(Post.objects.count(), 2) 88 | self.assertEqual(Blog.objects.count(), 2) 89 | self.assertEqual(Tag.objects.count(), 6) 90 | 91 | # check all through relationship 92 | # 1 posts X 2 authors X 2 93 | self.assertEqual(self.author.posts.through.objects.count(), 4) 94 | post = self.author.posts.all()[0] 95 | # 2 posts X 3 tags 96 | self.assertEqual(post.tags.through.objects.count(), 6) 97 | 98 | # Post 99 | 100 | fork = self.post.fork(deep=True) 101 | self.assertEqual(fork.pk, 3) 102 | 103 | # new counts 104 | self.assertEqual(Author.objects.count(), 6) 105 | self.assertEqual(Post.objects.count(), 3) 106 | self.assertEqual(Blog.objects.count(), 3) 107 | self.assertEqual(Tag.objects.count(), 9) 108 | 109 | # 1 posts X 2 authors X 3 110 | self.assertEqual(self.post.authors.through.objects.count(), 6) 111 | self.assertEqual(self.post.tags.through.objects.count(), 9) 112 | 113 | # Blog 114 | 115 | fork = self.blog.fork(deep=True) 116 | self.assertEqual(fork.pk, 4) 117 | 118 | # Tag 119 | 120 | fork = self.tag.fork(deep=True) 121 | self.assertEqual(fork.pk, 13) 122 | # 3 posts X 4 tags 123 | self.assertEqual(fork.post_set.through.objects.count(), 15) 124 | 125 | -------------------------------------------------------------------------------- /forkit/fork.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | from django.db import models 3 | from forkit import utils, signals 4 | from forkit.commit import commit_model_object 5 | 6 | def _fork_one2one(instance, value, field, direct, accessor, deep, **kwargs): 7 | "Due to the unique constraint, only deep forks can be performed." 8 | if deep: 9 | fork = _memoize_fork(value, deep=deep, **kwargs) 10 | 11 | if not direct: 12 | fork = utils.DeferredCommit(fork) 13 | 14 | instance._commits.defer(accessor, fork, direct=direct) 15 | 16 | def _fork_foreignkey(instance, value, field, direct, accessor, deep, **kwargs): 17 | if deep: 18 | if direct: 19 | fork = _memoize_fork(value, deep=deep, **kwargs) 20 | else: 21 | fork = [_memoize_fork(rel, deep=deep, **kwargs) for rel in value] 22 | fork = utils.DeferredCommit(fork) 23 | else: 24 | fork = value 25 | 26 | instance._commits.defer(accessor, fork, direct=direct) 27 | 28 | def _fork_many2many(instance, value, field, direct, accessor, deep, **kwargs): 29 | if deep: 30 | fork = [_memoize_fork(rel, deep=deep, **kwargs) for rel in value] 31 | if not direct: 32 | fork = utils.DeferredCommit(fork) 33 | else: 34 | fork = value 35 | 36 | instance._commits.defer(accessor, fork) 37 | 38 | def _fork_field(reference, instance, accessor, **kwargs): 39 | """Creates a copy of the reference value for the defined ``accessor`` 40 | (field). For deep forks, each related object is related objects must 41 | be created first prior to being recursed. 42 | """ 43 | value, field, direct, m2m = utils._get_field_value(reference, accessor) 44 | 45 | if value is None: 46 | return 47 | 48 | # recursive calls cannot be saved until everything has been traversed.. 49 | kwargs['commit'] = False 50 | 51 | if isinstance(field, models.OneToOneField): 52 | return _fork_one2one(instance, value, field, direct, 53 | accessor, **kwargs) 54 | 55 | if isinstance(field, models.ForeignKey): 56 | return _fork_foreignkey(instance, value, field, direct, 57 | accessor, **kwargs) 58 | 59 | if isinstance(field, models.ManyToManyField): 60 | return _fork_many2many(instance, value, field, direct, 61 | accessor, **kwargs) 62 | 63 | # non-relational field, perform a deepcopy to ensure no mutable nonsense 64 | setattr(instance, accessor, deepcopy(value)) 65 | 66 | def _memoize_fork(reference, **kwargs): 67 | "Resets the specified instance relative to ``reference``" 68 | # popped so it does not get included in the config for the signal 69 | memo = kwargs.pop('memo', None) 70 | 71 | # for every call, keep track of the reference and the instance being 72 | # acted on. this is used for recursive calls to related objects. this 73 | # ensures relationships that follow back up the tree are caught and are 74 | # merely referenced rather than traversed again. 75 | if memo is None: 76 | memo = utils.Memo() 77 | elif memo.has(reference): 78 | return memo.get(reference) 79 | 80 | # initialize and memoize new instance 81 | instance = reference.__class__() 82 | instance._commits = utils.Commits(reference) 83 | memo.add(reference, instance) 84 | 85 | # default configuration 86 | config = { 87 | 'fields': None, 88 | 'exclude': ['pk'], 89 | 'deep': False, 90 | 'commit': True, 91 | } 92 | 93 | # pop off and set any config params for signals 94 | for key in config.iterkeys(): 95 | if kwargs.has_key(key): 96 | config[key] = kwargs.pop(key) 97 | 98 | # pre-signal 99 | signals.pre_fork.send(sender=reference.__class__, reference=reference, 100 | instance=instance, config=config, **kwargs) 101 | 102 | fields = config['fields'] 103 | exclude = config['exclude'] 104 | deep = config['deep'] 105 | commit = config['commit'] 106 | 107 | # no fields are defined, so get the default ones for shallow or deep 108 | if not fields: 109 | fields = utils._default_model_fields(reference, exclude=exclude, deep=deep) 110 | 111 | # add arguments for downstream use 112 | kwargs.update({'deep': deep}) 113 | 114 | # iterate over each field and fork it!. nested calls will not commit, 115 | # until the recursion has finished 116 | for accessor in fields: 117 | _fork_field(reference, instance, accessor, memo=memo, **kwargs) 118 | 119 | # post-signal 120 | signals.post_fork.send(sender=reference.__class__, reference=reference, 121 | instance=instance, **kwargs) 122 | 123 | # as of now, this will only every be from a top-level call 124 | if commit: 125 | commit_model_object(instance, **kwargs) 126 | 127 | return instance 128 | 129 | def fork_model_object(reference, **kwargs): 130 | """Creates a fork of the reference object. If an object is supplied, it 131 | effectively gets reset relative to the reference object. 132 | """ 133 | return _memoize_fork(reference, **kwargs) 134 | -------------------------------------------------------------------------------- /forkit/utils.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.db.models import related 3 | 4 | class DeferredCommit(object): 5 | """Differentiates a non-direct related object that should be deferred 6 | during the commit phase. 7 | """ 8 | def __init__(self, value): 9 | self.value = value 10 | 11 | def __repr__(self): 12 | return ''.format(repr(self.value)) 13 | 14 | 15 | class Commits(object): 16 | "Stores pending direct and related commits relative to the reference." 17 | def __init__(self, reference): 18 | self.reference = reference 19 | self.direct = {} 20 | self.related = {} 21 | 22 | def defer(self, accessor, obj, direct=False): 23 | "Add object in the deferred queue for the given accessor." 24 | if direct: 25 | self.direct[accessor] = obj 26 | else: 27 | self.related[accessor] = obj 28 | 29 | def get(self, accessor, direct=False): 30 | "Get a deferred fork by the given accessor." 31 | if direct: 32 | return self.direct.get(accessor, None) 33 | return self.related.get(accessor, None) 34 | 35 | 36 | class Memo(object): 37 | "Memoizes reference objects and their instance equivalents." 38 | def __init__(self): 39 | self._memo = {} 40 | 41 | def _key(self, reference): 42 | if reference.pk: 43 | return id(reference.__class__), reference.pk 44 | return id(reference) 45 | 46 | def has(self, reference): 47 | key = self._key(reference) 48 | return self._memo.has_key(key) 49 | 50 | def add(self, reference, instance): 51 | key = self._key(reference) 52 | self._memo[key] = instance 53 | 54 | def get(self, reference): 55 | key = self._key(reference) 56 | return self._memo.get(key) 57 | 58 | def _get_field_by_accessor(instance, accessor): 59 | """Extends the model ``Options.get_field_by_name`` to look up reverse 60 | relationships by their accessor name. This gets memod on the first 61 | lookup. 62 | 63 | The memo will only be needed when the ``related_name`` attribute has 64 | not been set for reverse relationships. 65 | """ 66 | try: 67 | field, model, direct, m2m = instance._meta.get_field_by_name(accessor) 68 | 69 | if isinstance(field, related.RelatedObject): 70 | field = field.field 71 | # if this occurs, try related object accessor 72 | except models.FieldDoesNotExist, e: 73 | # check to see if this memo has been set 74 | if not hasattr(instance._meta, 'related_objects_by_accessor'): 75 | memo = {} 76 | 77 | # reverse foreign key and many-to-many rels 78 | related_objects = ( 79 | instance._meta.get_all_related_objects() + 80 | instance._meta.get_all_related_many_to_many_objects() 81 | ) 82 | 83 | for rel in iter(related_objects): 84 | memo[rel.get_accessor_name()] = rel 85 | 86 | instance._meta.related_objects_by_accessor = memo 87 | 88 | rel = instance._meta.related_objects_by_accessor.get(accessor, None) 89 | 90 | # if the related object still doesn't exist, raise the exception 91 | # that is present 92 | if rel is None: 93 | raise e 94 | 95 | field, model, direct, m2m = ( 96 | rel.field, 97 | rel.model, 98 | False, 99 | isinstance(rel.field, models.ManyToManyField) 100 | ) 101 | 102 | # ignoring ``model`` for now.. no use for it 103 | return field, direct, m2m 104 | 105 | def _get_field_value(instance, accessor): 106 | """Simple helper that returns the model's data value and catches 107 | non-existent related object lookups. 108 | """ 109 | field, direct, m2m = _get_field_by_accessor(instance, accessor) 110 | 111 | value = None 112 | # attempt to retrieve deferred values first, since they will be 113 | # the value once comitted. these will never contain non-relational 114 | # fields 115 | if hasattr(instance, '_commits'): 116 | if m2m: 117 | value = instance._commits.get(accessor, direct=False) 118 | else: 119 | value = instance._commits.get(accessor, direct=direct) 120 | if value and isinstance(value, DeferredCommit): 121 | value = value.value 122 | 123 | # deferred relations can never be a NoneType 124 | if value is None: 125 | try: 126 | value = getattr(instance, accessor) 127 | # catch foreign keys and one-to-one lookups 128 | except models.ObjectDoesNotExist: 129 | value = None 130 | # catch many-to-many or related foreign keys 131 | except ValueError: 132 | value = [] 133 | 134 | # get the queryset associated with the m2m or reverse foreign key. 135 | # logic broken up for readability 136 | if value and m2m or not direct and not isinstance(field, models.OneToOneField): 137 | if type(value) is not list: 138 | value = value.all() 139 | 140 | # ignoring ``model`` for now.. no use for it 141 | return value, field, direct, m2m 142 | 143 | def _default_model_fields(instance, exclude=('pk',), deep=False): 144 | "Aggregates the default set of fields for creating an object fork." 145 | if not exclude: 146 | exclude = [] 147 | # handle this special case.. 148 | else: 149 | exclude = list(exclude) 150 | if 'pk' in exclude: 151 | exclude.remove('pk') 152 | exclude.append(instance._meta.pk.name) 153 | 154 | fields = ( 155 | [f.name for f in instance._meta.fields + instance._meta.many_to_many] + 156 | [r.get_accessor_name() for r in instance._meta.get_all_related_many_to_many_objects()] 157 | ) 158 | 159 | if deep: 160 | fields += [r.get_accessor_name() for r in instance._meta.get_all_related_objects()] 161 | 162 | return set(fields) - set(exclude) 163 | 164 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Overview 2 | ======== 3 | Django-Forkit is composed of a set of utility functions for _forking_, 4 | _resetting_, and _diffing_ model objects. Below are a list of the current 5 | utility functions: 6 | 7 | forkit.tools.fork 8 | ----------------- 9 | Creates and returns a new object that is identical to ``reference``. 10 | 11 | - ``fields`` - A list of fields to fork. If a falsy value, the fields 12 | will be inferred depending on the value of ``deep``. 13 | - ``exclude`` - A list of fields to not fork (not applicable if ``fields`` 14 | is defined) 15 | - ``deep`` - If ``True``, traversing all related objects and creates forks 16 | of them as well, effectively creating a new _tree_ of objects. 17 | - ``commit`` - If ``True``, all forks (including related objects) will be saved 18 | in the order of dependency. If ``False``, all commits are stashed away until 19 | the root fork is committed. 20 | - ``**kwargs`` - Any additional keyword arguments are passed along to all signal 21 | receivers. Useful for altering runtime behavior in signal receivers. 22 | 23 | ```python 24 | fork(reference, [fields=None], [exclude=('pk',)], [deep=False], [commit=True], [**kwargs]) 25 | ``` 26 | 27 | forkit.tools.reset 28 | ------------------ 29 | Same parameters as above, except that an explicit ``instance`` is rquired and 30 | will result in an in-place update of ``instance``. For shallow resets, only the 31 | local non-relational fields will be updated. For deep resets, _direct_ 32 | foreign keys will be traversed and reset. _Many-to-many and reverse foreign keys 33 | are not attempted to be reset because the comparison between the related objects 34 | for ``reference`` and the related objects for ``instance`` becomes ambiguous._ 35 | 36 | ```python 37 | reset(reference, instance, [fields=None], [exclude=('pk',)], [deep=False], [commit=True], [**kwargs]) 38 | ``` 39 | 40 | forkit.tools.commit 41 | ------------------- 42 | Commits any unsaved changes to a forked or reset object. 43 | 44 | ```python 45 | commit(reference, [**kwargs]) 46 | ``` 47 | 48 | forkit.tools.diff 49 | ----------------- 50 | Performs a _diff_ between two model objects of the same type. The output is a 51 | ``dict`` of differing values relative to ``reference``. Thus, if 52 | ``reference.foo`` is ``bar`` and ``instance.foo`` is ``baz``, the output will 53 | be ``{'foo': 'baz'}``. _Note: deep diffs only work for simple non-circular 54 | relationships. Improved functionality is scheduled for a future release._ 55 | 56 | ```python 57 | diff(reference, instance, [fields=None], [exclude=('pk',)], [deep=False], [**kwargs]) 58 | ``` 59 | 60 | ForkableModel 61 | ------------- 62 | Also included is a ``Model`` subclass which has implements the above functions 63 | as methods. 64 | 65 | ```python 66 | from forkit.models import ForkableModel 67 | 68 | class Author(ForkableModel): 69 | first_name = models.CharField(max_length=30) 70 | last_name = models.CharField(max_length=30) 71 | ``` 72 | 73 | Let's create starting object: 74 | 75 | ```python 76 | author = Author(first_name='Byron', last_name='Ruth') 77 | author.save() 78 | ``` 79 | 80 | To create copy, simply call the ``fork`` method. 81 | 82 | ```python 83 | author_fork = author.fork() 84 | ``` 85 | 86 | When an object is forked, it immediately inherits it's data including 87 | related objects. 88 | 89 | ```python 90 | author_fork.first_name # Byron 91 | author_fork.last_name # Ruth 92 | ``` 93 | 94 | Let us change something on the fork and use the ``diff`` method to compare it 95 | against the original ``author``. It returns a dictionary of the differences 96 | between itself and the passed in object. 97 | 98 | ```python 99 | author_fork.first_name = 'Edward' 100 | author_fork.diff(author) # {'first_name': 'Edward'} 101 | ``` 102 | 103 | Once satisfied with the changes, simply call ``commit``. 104 | 105 | ```python 106 | author_fork.commit() 107 | ``` 108 | 109 | Signals 110 | ======= 111 | For each of the utility function above, ``pre_FOO`` and ``post_FOO`` signals 112 | are sent allowing for a decoupled approached for customizing behavior, especially 113 | when performing deep operations. 114 | 115 | forkit.signals.pre_fork 116 | ----------------------- 117 | 118 | - ``sender`` - the model class of the instance 119 | - ``reference`` - the reference object the fork is being created from 120 | - ``instance`` - the forked object itself 121 | - ``config`` - a ``dict`` of the keyword arguments passed into ``forkit.tools.fork`` 122 | 123 | forkit.signals.post_fork 124 | ----------------------- 125 | 126 | - ``sender`` - the model class of the instance 127 | - ``reference`` - the reference object the fork is being created from 128 | - ``instance`` - the forked object itself 129 | 130 | forkit.signals.pre_reset 131 | ----------------------- 132 | 133 | - ``sender`` - the model class of the instance 134 | - ``reference`` - the reference object the instance is being reset relative to 135 | - ``instance`` - the object being reset 136 | - ``config`` - a ``dict`` of the keyword arguments passed into ``forkit.tools.reset`` 137 | 138 | forkit.signals.post_reset 139 | ----------------------- 140 | 141 | - ``sender`` - the model class of the instance 142 | - ``reference`` - the reference object the instance is being reset relative to 143 | - ``instance`` - the object being reset 144 | 145 | forkit.signals.pre_commit 146 | ----------------------- 147 | 148 | - ``sender`` - the model class of the instance 149 | - ``reference`` - the reference object the instance has been derived 150 | - ``instance`` - the object to be committed 151 | 152 | forkit.signals.post_commit 153 | ----------------------- 154 | 155 | - ``sender`` - the model class of the instance 156 | - ``reference`` - the reference object the instance has been derived 157 | - ``instance`` - the object that has been committed 158 | 159 | forkit.signals.pre_diff 160 | ----------------------- 161 | 162 | - ``sender`` - the model class of the instance 163 | - ``reference`` - the reference object the instance is being diffed against 164 | - ``instance`` - the object being diffed with 165 | - ``config`` - a ``dict`` of the keyword arguments passed into ``forkit.tools.diff`` 166 | 167 | forkit.signals.post_diff 168 | ----------------------- 169 | 170 | - ``sender`` - the model class of the instance 171 | - ``reference`` - the reference object the instance is being diffed against 172 | - ``instance`` - the object being diffed with 173 | - ``diff`` - the diff between the ``reference`` and ``instance`` 174 | -------------------------------------------------------------------------------- /forkit/tests/colorize.py: -------------------------------------------------------------------------------- 1 | # -*- coding: iso-8859-1 -*- 2 | # 3 | # Code coverage colorization: 4 | # - sébastien Martini 5 | # * 5/24/2006 fixed: bug when code is completely covered (Kenneth Lind). 6 | # 7 | # Original recipe: 8 | # http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/52298 9 | # 10 | # Original Authors: 11 | # - Jürgen Hermann 12 | # - Mike Brown 13 | # - Christopher Arndt 14 | # 15 | import cgi 16 | import string 17 | import sys 18 | import cStringIO 19 | import os 20 | import keyword 21 | import token 22 | import tokenize 23 | 24 | _VERBOSE = False 25 | 26 | _KEYWORD = token.NT_OFFSET + 1 27 | _TEXT = token.NT_OFFSET + 2 28 | 29 | _css_classes = { 30 | token.NUMBER: 'number', 31 | token.OP: 'operator', 32 | token.STRING: 'string', 33 | tokenize.COMMENT: 'comment', 34 | token.NAME: 'name', 35 | token.ERRORTOKEN: 'error', 36 | _KEYWORD: 'keyword', 37 | _TEXT: 'text', 38 | } 39 | 40 | _HTML_HEADER = """\ 41 | 43 | 44 | 45 | code coverage of %(title)s 46 | 47 | 48 | 82 | 83 | 84 | 85 | """ 86 | 87 | _HTML_FOOTER = """\ 88 | 89 | 90 | """ 91 | 92 | class Parser: 93 | """ Send colored python source. 94 | """ 95 | def __init__(self, raw, out=sys.stdout, not_covered=[]): 96 | """ Store the source text. 97 | """ 98 | self.raw = string.strip(string.expandtabs(raw)) 99 | self.out = out 100 | self.not_covered = not_covered # not covered list of lines 101 | self.cover_flag = False # is there a tag opened? 102 | 103 | def format(self): 104 | """ Parse and send the colored source. 105 | """ 106 | # store line offsets in self.lines 107 | self.lines = [0, 0] 108 | pos = 0 109 | while 1: 110 | pos = string.find(self.raw, '\n', pos) + 1 111 | if not pos: break 112 | self.lines.append(pos) 113 | self.lines.append(len(self.raw)) 114 | 115 | # parse the source and write it 116 | self.pos = 0 117 | text = cStringIO.StringIO(self.raw) 118 | self.out.write('
\n')
119 |         try:
120 |             tokenize.tokenize(text.readline, self)
121 |         except tokenize.TokenError, ex:
122 |             msg = ex[0]
123 |             line = ex[1][0]
124 |             self.out.write("

ERROR: %s

%s\n" % ( 125 | msg, self.raw[self.lines[line]:])) 126 | if self.cover_flag: 127 | self.out.write('') 128 | self.cover_flag = False 129 | self.out.write('\n
') 130 | 131 | def __call__(self, toktype, toktext, (srow,scol), (erow,ecol), line): 132 | """ Token handler. 133 | """ 134 | if _VERBOSE: 135 | print "type", toktype, token.tok_name[toktype], "text", toktext, 136 | print "start", srow,scol, "end", erow,ecol, "
" 137 | 138 | # calculate new positions 139 | oldpos = self.pos 140 | newpos = self.lines[srow] + scol 141 | self.pos = newpos + len(toktext) 142 | 143 | if not self.cover_flag and srow in self.not_covered: 144 | self.out.write('') 145 | self.cover_flag = True 146 | 147 | # handle newlines 148 | if toktype in [token.NEWLINE, tokenize.NL]: 149 | if self.cover_flag: 150 | self.out.write('') 151 | self.cover_flag = False 152 | 153 | # send the original whitespace, if needed 154 | if newpos > oldpos: 155 | self.out.write(self.raw[oldpos:newpos]) 156 | 157 | # skip indenting tokens 158 | if toktype in [token.INDENT, token.DEDENT]: 159 | self.pos = newpos 160 | return 161 | 162 | # map token type to a color group 163 | if token.LPAR <= toktype and toktype <= token.OP: 164 | toktype = token.OP 165 | elif toktype == token.NAME and keyword.iskeyword(toktext): 166 | toktype = _KEYWORD 167 | css_class = _css_classes.get(toktype, 'text') 168 | 169 | # send text 170 | self.out.write('' % (css_class,)) 171 | self.out.write(cgi.escape(toktext)) 172 | self.out.write('') 173 | 174 | 175 | class MissingList(list): 176 | def __init__(self, i): 177 | list.__init__(self, i) 178 | 179 | def __contains__(self, elem): 180 | for i in list.__iter__(self): 181 | v_ = m_ = s_ = None 182 | try: 183 | v_ = int(i) 184 | except ValueError: 185 | m_, s_ = i.split('-') 186 | if v_ is not None and v_ == elem: 187 | return True 188 | elif (m_ is not None) and (s_ is not None) and \ 189 | (int(m_) <= elem) and (elem <= int(s_)): 190 | return True 191 | return False 192 | 193 | 194 | def colorize_file(filename, outstream=sys.stdout, not_covered=[]): 195 | """ 196 | Convert a python source file into colorized HTML. 197 | 198 | Reads file and writes to outstream (default sys.stdout). 199 | """ 200 | fo = file(filename, 'rb') 201 | try: 202 | source = fo.read() 203 | finally: 204 | fo.close() 205 | outstream.write(_HTML_HEADER % {'title': os.path.basename(filename)}) 206 | Parser(source, out=outstream, 207 | not_covered=MissingList((not_covered and \ 208 | not_covered.split(', ')) or \ 209 | [])).format() 210 | outstream.write(_HTML_FOOTER) 211 | --------------------------------------------------------------------------------