Anonymous avatar Anonymous committed b391e39

initial commit with basic directories and modules layout

Comments (0)

Files changed (17)

+syntax: glob
+
+*.pyc
+local_settings.py
+*.kpf
+*.sqlite*
+MANIFEST
+dist/*
+build/*
+-*- markdown -*-
+
+## Description
+
+`django-composition` provides the abstract way to denormalize data from your models in simple declarative way through special generic model field called `CompositionField`.
+
+Most cases of data denormalization are pretty common so `django-composition` has several "short-cuts" fields that handles most of them.
+
+## Base concept
+
+
+## Short-cuts
+
+Here is the list of provided short-cut fields:
+
+ - `ForeignAttributeField`
+
+ - `ChildsAggregationField`
+ 
+ - `AtributesAggregationField`

composition/__init__.py

+from composition.base import CompositionField
+

composition/base.py

+from django.db import models
+
+from composition.meta import CompositionMeta
+
+class CompositionField(object):
+    def __init__(self, native, trigger=None, commons={},\
+                     commit=True, update_method={}):
+        self.internal_init(native, trigger, commons, commit, update_method)
+
+    def internal_init(self, native=None, trigger=None, commons={},\
+                     commit=True, update_method={}):
+        """
+            CompositionField class that patches native field
+            with custom `contribute_to_class` method
+
+            Params:
+                 * native - Django field instance for current compostion field
+                 * trigger - one or some numberr of triggers that handle composition.
+                    Trigger is a dict with allowed keys:
+                      * on - signal or list of signals that this field handles
+                      * do - signals handler, with 3 params:
+                               * related instance
+                               * instance (that comes with signal send)
+                               * concrete signal (one from `on` value)
+                      * field_holder_getter - function that gets instance(that comes with signal send)\
+                                              as parameter and returns field holder
+                                              object (related instance)
+                      * sender - signal sender
+                      * sender_model - model instance or model name that send signal
+                      * commit - flag that indicates save instance after trigger appliance or not
+                 * commons - a trigger like field with common settings
+                             for all given triggers
+                 * update_method - dict for customization of update_method. Allowed params:
+                        * initial - initial value to field before applince of method
+                        * do - index of update trigger or trigger itself
+                        * queryset - query set or callable(with one param - `instance` of an holder model)
+                                that have to retun something iterable
+                        * name - custom method name instead of `update_FOO`
+        """
+        if native is not None:
+            import new
+            self.__class__ = new.classobj(
+                self.__class__.__name__,
+                tuple([self.__class__, native.__class__] + list(self.__class__.__mro__[1:])),
+                {}
+            )
+
+            self.__dict__.update(native.__dict__)
+
+        self._c_native = native
+
+        self._c_trigger = trigger
+        self._c_commons = commons
+        self._c_commit = commit
+        self._c_update_method = update_method
+
+    def contribute_to_class(self, cls, name):
+        self._c_name = name
+
+        if not self._c_native:
+            models.signals.class_prepared.connect(
+                self.deferred_contribute_to_class,
+                sender=cls
+            )
+        else:
+            self._composition_meta = self.create_meta(cls)
+            return self._c_native.__class__.contribute_to_class(self, cls, name)
+
+    def create_meta(self, cls):
+        return CompositionMeta(
+            cls, self._c_native, self._c_name, self._c_trigger,\
+            self._c_commons, self._c_commit, self._c_update_method
+        )
+
+    def deferred_contribute_to_class(self, sender, **kwargs):
+        cls = sender
+
+        self.introspect_class(cls)
+        self._composition_meta = self.create_meta(cls)
+        return self._c_native.__class__.contribute_to_class(self, cls, self._c_name)
+
+    def introspect_class(self, cls):
+        pass

composition/meta.py

+from django.db import models
+from django.utils.itercompat import is_iterable
+
+from composition.trigger import Trigger
+
+class CompositionMeta(object):
+    def __init__(self, model, field, name, trigger,\
+                  commons, commit, update_method):#TODO: remove commit param
+        self.model = model
+        self.name = name
+        self.trigger = []
+
+        if not commons:
+            commons = {}
+        self.commons = commons
+
+        if not is_iterable(trigger) or isinstance(trigger, dict):
+            trigger = [trigger]
+
+        trigger_defaults = dict(
+            sender_model=model,
+            sender=None,
+            on=[models.signals.post_save],
+            field_holder_getter=lambda instance: instance,
+            field_name=name,
+            commit=True
+        )
+        trigger_defaults.update(commons)
+
+        if not len(trigger):
+            raise ValueError("At least one trigger must be specefied")
+
+        for t in trigger:
+            trigger_meta = trigger_defaults.copy()
+            trigger_meta.update(t)
+
+            trigger_obj = Trigger(**trigger_meta)
+            trigger_obj.connect()
+
+            self.trigger.append(trigger_obj)
+
+        update_method_defaults = dict(
+            initial=None,
+            name="update_%s" % name,
+            do=self.trigger[0],
+            queryset=None
+        )
+        update_method_defaults.update(update_method)
+
+        if isinstance(update_method_defaults["do"], (int, long)):
+            n = update_method_defaults["do"]
+            if n >= len(self.trigger):
+                raise ValueError("Update method trigger must be index of trigger list")
+            update_method_defaults["do"] = self.trigger[update_method_defaults["do"]]
+
+        self.update_method = update_method_defaults
+
+        setattr(model, self.update_method["name"], lambda instance: self._update_method(instance))
+        setattr(model, "freeze_%s" % name, lambda instance: self._freeze_method(instance))
+
+    def togle_freeze(self):
+        for t in self.trigger:
+            t.freeze = not t.freeze
+
+    def _update_method(self, instance):
+        """
+            Generic `update_FOO` method that is connected to model
+        """
+        qs_getter = self.update_method["queryset"]
+        if qs_getter is None:
+            qs_getter = [instance]
+
+        trigger = self.update_method["do"]
+
+        setattr(instance, trigger.field_name, self.update_method["initial"])
+        if callable(qs_getter):
+            qs = qs_getter(instance)
+        else:
+            qs = qs_getter
+
+        if not is_iterable(qs):
+            qs = [qs]
+
+        for obj in qs:
+            setattr(
+                instance,
+                trigger.field_name,
+                trigger.do(instance, obj, trigger.on[0])
+            )
+
+        instance.save()
+
+    def _freeze_method(self, instance):
+        """
+            Generic `freeze_FOO` method that is connected to model
+        """
+        self.toggle_freeze()

Empty file added.

composition/shortcuts/__init__.py

+from composition.shortcuts.foreign_attribute import ForeignAttributeField
+from composition.shortcuts.attributes_aggregation import AttributesAggregationField
+from composition.shortcuts.childs_aggregation import ChildsAggregationField

composition/shortcuts/attributes_aggregation.py

+from composition.base import CompositionField
+
+class AttributesAggregationField(CompositionField):
+    def __init__(self, field, do, native=None):
+        self.field = field
+        self.do = do
+        self.native = native

composition/shortcuts/childs_aggregation.py

+from composition.base import CompositionField
+
+class ChildsAggregationField(CompositionField):
+    def __init__(self, field, do, native=None, signal=None, instance_getter=None):
+        self.field = field
+        self.do = do
+        self.native = native
+        self.signal = signal
+        self.instance_getter = instance_getter

composition/shortcuts/foreign_attribute.py

+from copy import deepcopy
+
+from django.db import models
+from django.db.models.related import RelatedObject
+
+from composition.base import CompositionField
+
+class ForeignAttributeField(CompositionField):
+    """
+        Composition field that can track changes of related objects attributes.
+    """
+    def __init__(self, field, native=None):
+        """
+            field - path to related field, e.g. 'director.country.name'
+            native - field instance to store value
+        """
+        self.field = field
+        self.native = native
+
+        self.internal_init()
+
+    def introspect_class(self, cls):
+        bits = self.field.split(".")
+
+        if len(bits) < 2:
+            raise ValueError("Illegal path to foreign field")
+
+        foreign_field = None
+
+        related_models_chain = [cls]
+        related_names_chain = []
+
+        for bit in bits[:-1]:
+            meta = related_models_chain[-1]._meta
+
+            try:
+                foreign_field = meta.get_field(bit)
+            except models.FieldDoesNotExist:
+                raise ValueError("Field '%s' does not exist" % bit)
+
+            if isinstance(foreign_field, models.ForeignKey):
+                if isinstance(foreign_field.rel.to, basestring):
+                    raise ValueError("Model with name '%s' must be class instance not string" % foreign_field.rel.to)
+
+                related_name = foreign_field.rel.related_name
+                if not related_name:
+                    related_name = RelatedObject(
+                        foreign_field.rel.to,
+                        related_models_chain[-1],
+                        foreign_field
+                    ).get_accessor_name()
+
+                related_models_chain.append(foreign_field.rel.to)
+                related_names_chain.append(related_name)
+            else:
+                raise ValueError("Foreign fields in path must be ForeignField"
+                                 "instances except last. Got %s" % foreign_field.__name__)
+
+        native = self.native
+        if not native:
+            field_name = bits[-1]
+            try:
+                native = deepcopy(related_models_chain[-1]._meta.get_field(field_name))
+                native.creation_counter = models.Field.creation_counter
+                models.Field.creation_counter += 1
+            except models.FieldDoesNotExist:
+                raise ValueError("Leaf field '%s' does not exist" % field_name)
+
+        def get_root_instances(instance, chain):
+            attr = getattr(instance, chain.pop()).all()
+
+            if chain:
+                for obj in attr:
+                    for inst in get_root_instances(
+                        obj,
+                        chain
+                    ):
+                        yield inst
+            else:
+                for obj in attr:
+                    yield obj
+
+        def get_leaf_instance(instance, chain):
+            for bit in chain:
+                instance = getattr(instance, bit)
+
+            return instance
+
+        self.internal_init(
+            native=native,
+            trigger=[
+                dict(
+                    on=(models.signals.post_save, models.signals.post_delete),
+                    sender_model=related_models_chain[-1],
+                    do=lambda holder, foreign, signal: getattr(foreign, bits[-1]),
+                    field_holder_getter=lambda foreign: get_root_instances(foreign, related_names_chain[:])
+                ),
+                dict(
+                    on=models.signals.pre_save,
+                    sender_model=related_models_chain[0],
+                    do=lambda holder, _, signal: get_leaf_instance(holder, bits[:]),
+                    commit=False, # to prevent recursion `save` method call
+                )
+            ],
+            update_method=dict(
+                queryset=lambda holder: get_leaf_instance(holder, bits[:-1])#FIXME: rename queryset
+            )
+        )
+        # TODO: add support for selective object handling to prevent pre_save unneeded work

composition/tests/__init__.py

+from django.test import TestCase
+
+from composition.tests.models import Event
+from composition.tests.low import *
+#from composition.tests.high import *
+
+class CompositionFieldTest(TestCase):
+    def test_model_attribute_exist(self):
+        self.assert_(hasattr(Event._meta.get_field("visit_count"), "_composition_meta"), "Field`s composition meta does not exist")
+
+        self.assert_(hasattr(Event, "sync_visit_count"), "Update method does not exist")
+        self.assert_(hasattr(Event, "freeze_visit_count"), "Freeze method does not exist")

composition/tests/generic.py

+from composition.tests import models
+
+class BaseTest(object):
+    def renew_object(self, obj):
+        instance = getattr(self, obj)
+        setattr(self, obj, instance.__class__.objects.get(pk=instance.pk))
+
+class GenericEventTest(BaseTest):
+    def setUp(self):
+        self.event = self.event_model.objects.create()
+
+        for i in range(5):
+            self.visit_model.objects.create(event=self.event)
+
+    def test_event(self):
+        self.renew_object("event")
+        self.assertEqual(self.event.visit_count, 5)
+
+        self.event.visit_count = 0
+        self.event.save()
+
+        self.event.sync_visit_count()
+
+        self.renew_object("event")
+        self.assertEqual(self.event.visit_count, 5)
+
+class GenericMovieTest(BaseTest):
+    def setUp(self):
+        self.country = models.Country.objects.create(name="USA")
+        self.person = models.Person.objects.create(
+            name="George Lucas",
+            country=self.country
+        )
+
+        self.movie = self.movie_model(
+            title="Star Wars Episode IV: A New Hope",
+            director=self.person
+        )
+        self.movie.save()
+
+    def test_movie(self):
+        self.movie.update_headline()
+
+        self.renew_object("movie")
+        self.assertEqual(
+            self.movie.headline,
+            "Star Wars Episode IV: A New Hope, by George Lucas"
+        )
+
+        self.person.name = "George W. Lucas"
+        self.person.save()
+
+        self.renew_object("movie")
+        self.assertEqual(
+            self.movie.headline,
+            "Star Wars Episode IV: A New Hope, by George W. Lucas"
+        )
+
+class GenericPostTest(BaseTest):
+    def setUp(self):
+        self.post = self.post_model.objects.create()
+
+        for i in range(5):
+            self.comment_model.objects.create(post=self.post)
+
+    def test_post(self):
+        self.renew_object("post")
+        self.assertEqual(self.post.comment_count, 5)
+
+        self.post.comment_count = 0
+        self.post.save()
+
+        self.post.update_comment_count()
+
+        self.renew_object("post")
+        self.assertEqual(self.post.comment_count, 5)

composition/tests/high.py

+from django.test import TestCase
+
+from composition.tests.generic import *
+
+from composition.tests.models import HLMovie#, HLPost, HLComment
+
+class HighMovieTest(GenericMovieTest, TestCase):
+    movie_model = HLMovie
+
+    def test_movie_director_name(self):
+        self.renew_object("movie")
+        self.assertEqual(
+                        self.movie.director_name,
+                        "George Lucas"
+                    )
+
+        self.assertEqual(
+                        self.movie.director_country,
+                        "USA"
+                    )
+
+        self.person.name = "George W. Lucas"
+        self.person.save()
+
+        self.person.country.name = "United States"
+        self.person.country.save()
+
+        self.renew_object("movie")
+        self.assertEqual(
+                        self.movie.director_name,
+                        "George W. Lucas"
+                    )
+        self.assertEqual(
+                        self.movie.director_country,
+                        "United States"
+                    )
+"""
+class HighPostTest(GenericPostTest, TestCase):
+    post_model = HLPost
+    comment_model = HLComment
+"""

composition/tests/low.py

+"""Test for composition low level interface"""
+from django.test import TestCase
+
+from composition.tests.generic import *
+
+from composition.tests.models import Movie, Visit, Person, Event,\
+                                                  Post, Comment
+
+class LowEventTest(GenericEventTest, TestCase):
+    event_model = Event
+    visit_model = Visit
+
+class LowMovieTest(GenericMovieTest, TestCase):
+    movie_model = Movie
+
+class LowPostTest(GenericPostTest, TestCase):
+    post_model = Post
+    comment_model = Comment

composition/tests/models.py

+from django.db import models
+from django.db.models import signals
+
+from composition import CompositionField
+from composition.shortcuts import ForeignAttributeField#,\
+                                       #ChildsAggregationField, AttributesAggregationField
+
+
+D = dict
+
+class Visit(models.Model):
+    event = models.ForeignKey("Event")
+
+    class Meta:
+        app_label = "composition"
+
+class Event(models.Model):
+    visit_count=CompositionField(
+        native=models.PositiveIntegerField(default=0),
+        trigger=[# Only for test. Don't do incremental counts in production code
+            D(
+                on=signals.post_save,
+                do=lambda event, visit, signal: event.visit_count + 1
+            ),
+            D(
+                on=signals.post_delete,
+                do=lambda event, visit, signal: event.visit_count - 1
+            )
+        ],
+        commons=D(
+            sender_model="composition.Visit",
+            field_holder_getter=lambda visit: visit.event,
+        ),
+        commit=True,
+        update_method=D(
+            do=0,
+            initial=0,
+            queryset=lambda event: event.visit_set.all(),
+            name="sync_visit_count"
+        )
+    )
+
+    class Meta:
+        app_label = "composition"
+
+class Country(models.Model):
+    name = models.CharField(max_length=250)
+
+    class Meta:
+        app_label="composition"
+
+class Person(models.Model):
+    name = models.CharField(max_length=250)
+    country = models.ForeignKey(Country)
+
+    class Meta:
+        app_label="composition"
+
+class Movie(models.Model):
+    title = models.CharField(max_length=250)
+    director = models.ForeignKey(Person)
+
+    headline = CompositionField(
+        native=models.CharField(max_length=250),
+        trigger=D(
+            sender_model=Person,
+            field_holder_getter=lambda director: director.movie_set.all(),
+            do=lambda movie, _, signal: "%s, by %s" % (movie.title, movie.director.name)
+        )
+    )
+
+    class Meta:
+        app_label = "composition"
+
+class Comment(models.Model):
+    post = models.ForeignKey("Post", related_name="comments")
+
+    class Meta:
+        app_label = "composition"
+
+class Post(models.Model):
+    comment_count=CompositionField(
+        native=models.PositiveIntegerField(default=0),
+        trigger=D(
+            on=(signals.post_save, signals.post_delete),
+            do=lambda post, comment, signal: post.comments.count(),
+            sender_model=Comment,
+            field_holder_getter=lambda comment: comment.post,
+        )
+    )
+
+    class Meta:
+        app_label = "composition"
+
+"""
+class HLComment(models.Model):
+    post = models.ForeignKey("HLPost", related_name="comments")
+
+    class Meta:
+        app_label = "composition"
+
+class HLPost(models.Model):
+    comment_count = ChildsAggregation(
+        "comments",
+        lambda post: post.comments.count(),
+        native=models.PositiveIntegerField()
+    )
+
+    class Meta:
+        app_label = "composition"
+"""
+
+
+class HLMovie(models.Model):
+    title = models.CharField(max_length=250)
+    director = models.ForeignKey(Person)
+
+    #headline = AttributesAggregationField(
+    #             native=models.CharField(max_length=250),
+    #             fields=["director"],
+    #             do=lambda movie: "%s, by %s" % (movie.title, movie.director.name)
+    #          )
+
+    director_name = ForeignAttributeField("director.name")
+    director_country = ForeignAttributeField("director.country.name")
+
+    class Meta:
+        app_label = "composition"
+
+"""
+class HLIngridient(models.Model):
+    name = models.CharField(max_length=100)
+
+class HLFood(models.Model):
+    ingridients = models.ManyToManyField(HLIngridient)
+
+    ingridients_str = ChildsAggregation(
+        native=models.CharField(max_length=250),
+        field="ingridients",
+        do=lambda food: ", ",join(food.ingridients.all(),
+        signal=ingridient_added,
+        instance_getter=lambda instance, to, *args, **kwargs: to
+    )
+
+    class Meta:
+        app_label = "composition"
+"""

composition/trigger.py

+from django.db import models
+from django.utils.itercompat import is_iterable
+
+class Trigger(object):
+    def __init__(self, do, on, field_name, sender, sender_model, commit,\
+                 field_holder_getter):
+        self.freeze = False
+        self.field_name = field_name
+        self.commit = commit
+
+        if sender_model and not sender:
+            if isinstance(sender_model, basestring):
+                sender_model = models.get_model(*sender_model.split(".", 1))
+
+            self.sender = self.sender_model = sender_model
+        else:
+            self.sender = sender
+            self.sender_model = sender_model
+
+        if not do:
+            raise ValueError("`do` action not defined for trigger")
+        self.do = do
+
+        if not is_iterable(on):
+            on = [on]
+        self.on = on
+
+        self.field_holder_getter = field_holder_getter
+
+    def connect(self):
+        """
+           Connects trigger's handler to all of its signals
+        """
+        for signal in self.on:
+            signal.connect(self.handler, sender=self.sender)
+
+    def handler(self, signal, instance=None, **kwargs):
+        """
+            Signal handler
+        """
+        if self.freeze:
+            return
+
+        objects = self.field_holder_getter(instance)
+        if not is_iterable(objects):
+            objects = [objects]
+
+        for obj in objects:
+            setattr(obj, self.field_name, self.do(obj, instance, signal))
+
+            if self.commit:
+                obj.save()
+#!/usr/bin/env python
+
+from distutils.core import setup
+
+def get_description():
+    import os
+    return "".join(
+        file(
+            os.path.join(os.path.dirname(os.path.normpath(__file__)),
+            'README'
+        ), 'r').readlines()[1:] # strip markdown marker line
+    )
+
+setup(
+    name="django-composition",
+    version="0.2",
+
+    license="New BSD License",
+
+    author='Alex Koshelev',
+    author_email="daevaorn@gmail.com",
+
+    url="http://bitbucket.org/daevaorn/django-composition/",
+
+    packages=[
+        "composition",
+        "composition.shortcuts",
+        "composition.tests"
+    ],
+
+    description=get_description(),
+
+    classifiers=[
+        "Framework :: Django",
+        "License :: OSI Approved :: BSD License",
+    ]
+)
Tip: Filter by directory path e.g. /media app.js to search for public/media/app.js.
Tip: Use camelCasing e.g. ProjME to search for ProjectModifiedEvent.java.
Tip: Filter by extension type e.g. /repo .js to search for all .js files in the /repo directory.
Tip: Separate your search with spaces e.g. /ssh pom.xml to search for src/ssh/pom.xml.
Tip: Use ↑ and ↓ arrow keys to navigate and return to view the file.
Tip: You can also navigate files with Ctrl+j (next) and Ctrl+k (previous) and view the file with Ctrl+o.
Tip: You can also navigate files with Alt+j (next) and Alt+k (previous) and view the file with Alt+o.