├── test └── example │ ├── __init__.py │ ├── people │ ├── __init__.py │ ├── models.py │ ├── tests.py │ └── fixtures │ │ └── testing.json │ ├── manage.py │ ├── urls.py │ └── settings.py ├── MANIFEST.in ├── .gitignore ├── setup.py ├── UNLICENSE ├── src └── djqmixin │ └── __init__.py ├── README.md └── distribute_setup.py /test/example/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/example/people/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include distribute_setup.py 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.pyc 3 | *.pyo 4 | .DS_Store 5 | build 6 | dist 7 | MANIFEST 8 | test/example/*.sqlite3 9 | -------------------------------------------------------------------------------- /test/example/people/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django.db import models 4 | from djqmixin import Manager, QMixin 5 | 6 | 7 | class AgeMixin(QMixin): 8 | def minors(self): 9 | return self.filter(age__lt=18) 10 | 11 | def adults(self): 12 | return self.filter(age__gte=18) 13 | 14 | 15 | 16 | class Group(models.Model): 17 | pass 18 | 19 | 20 | class Person(models.Model): 21 | 22 | group = models.ForeignKey(Group, related_name='people') 23 | age = models.PositiveIntegerField() 24 | 25 | objects = Manager.include(AgeMixin)() 26 | -------------------------------------------------------------------------------- /test/example/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from django.core.management import execute_manager 3 | try: 4 | import settings # Assumed to be in the same directory. 5 | except ImportError: 6 | import sys 7 | sys.stderr.write("Error: Can't find the file 'settings.py' in the directory containing %r. It appears you've customized things.\nYou'll have to run django-admin.py, passing it your settings module.\n(If the file settings.py does indeed exist, it's causing an ImportError somehow.)\n" % __file__) 8 | sys.exit(1) 9 | 10 | if __name__ == "__main__": 11 | execute_manager(settings) 12 | -------------------------------------------------------------------------------- /test/example/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls.defaults import * 2 | 3 | # Uncomment the next two lines to enable the admin: 4 | # from django.contrib import admin 5 | # admin.autodiscover() 6 | 7 | urlpatterns = patterns('', 8 | # Example: 9 | # (r'^example/', include('example.foo.urls')), 10 | 11 | # Uncomment the admin/doc line below and add 'django.contrib.admindocs' 12 | # to INSTALLED_APPS to enable admin documentation: 13 | # (r'^admin/doc/', include('django.contrib.admindocs.urls')), 14 | 15 | # Uncomment the next line to enable the admin: 16 | # (r'^admin/', include(admin.site.urls)), 17 | ) 18 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | import re 6 | 7 | from distribute_setup import use_setuptools; use_setuptools() 8 | from setuptools import setup, find_packages 9 | 10 | 11 | rel_file = lambda *args: os.path.join(os.path.dirname(os.path.abspath(__file__)), *args) 12 | 13 | def read_from(filename): 14 | fp = open(filename) 15 | try: 16 | return fp.read() 17 | finally: 18 | fp.close() 19 | 20 | def get_version(): 21 | data = read_from(rel_file('src', 'djqmixin', '__init__.py')) 22 | return re.search(r"__version__ = '([^']+)'", data).group(1) 23 | 24 | 25 | setup( 26 | name = 'django-qmixin', 27 | version = get_version(), 28 | author = "Zachary Voase", 29 | author_email = "zacharyvoase@me.com", 30 | url = 'http://github.com/zacharyvoase/django-qmixin', 31 | description = "A Django app for extending managers and the querysets they produce.", 32 | packages = find_packages(where='src'), 33 | package_dir = {'': 'src'}, 34 | ) 35 | -------------------------------------------------------------------------------- /UNLICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to -------------------------------------------------------------------------------- /test/example/people/tests.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django.db import models 4 | from django.test import TestCase 5 | 6 | from people.models import Group, Person 7 | 8 | 9 | class SimpleTest(TestCase): 10 | 11 | fixtures = ['testing'] 12 | 13 | def test_manager(self): 14 | self.failUnless(isinstance( 15 | Person.objects.minors(), 16 | models.query.QuerySet)) 17 | 18 | self.failUnlessEqual( 19 | pks(Person.objects.minors()), 20 | pks(Person.objects.filter(age__lt=18))) 21 | 22 | self.failUnless(isinstance( 23 | Person.objects.adults(), 24 | models.query.QuerySet)) 25 | 26 | self.failUnlessEqual( 27 | pks(Person.objects.adults()), 28 | pks(Person.objects.filter(age__gte=18))) 29 | 30 | def test_qset(self): 31 | self.failUnless(isinstance( 32 | Person.objects.all().minors(), 33 | models.query.QuerySet)) 34 | 35 | self.failUnlessEqual( 36 | pks(Person.objects.all().minors()), 37 | pks(Person.objects.filter(age__lt=18))) 38 | 39 | self.failUnless(isinstance( 40 | Person.objects.all().adults(), 41 | models.query.QuerySet)) 42 | 43 | self.failUnlessEqual( 44 | pks(Person.objects.all().adults()), 45 | pks(Person.objects.filter(age__gte=18))) 46 | 47 | 48 | class RelationTest(TestCase): 49 | 50 | fixtures = ['testing'] 51 | 52 | def test(self): 53 | for group in Group.objects.all(): 54 | self.failUnless(isinstance( 55 | group.people.all(), 56 | models.query.QuerySet)) 57 | 58 | self.failUnless(isinstance( 59 | group.people.minors(), 60 | models.query.QuerySet)) 61 | 62 | self.failUnlessEqual( 63 | pks(group.people.minors()), 64 | pks(group.people.filter(age__lt=18))) 65 | 66 | self.failUnless(isinstance( 67 | group.people.adults(), 68 | models.query.QuerySet)) 69 | 70 | self.failUnlessEqual( 71 | pks(group.people.adults()), 72 | pks(group.people.filter(age__gte=18))) 73 | 74 | 75 | def pks(qset): 76 | """Return the list of primary keys for the results of a QuerySet.""" 77 | 78 | return sorted(tuple(qset.values_list('pk', flat=True))) 79 | -------------------------------------------------------------------------------- /test/example/settings.py: -------------------------------------------------------------------------------- 1 | # Django settings for example project. 2 | 3 | import os 4 | import sys 5 | 6 | sys.path.append(os.path.dirname(os.path.abspath(__file__))) 7 | sys.path.append(os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'src')) 8 | 9 | 10 | DEBUG = True 11 | TEMPLATE_DEBUG = DEBUG 12 | 13 | ADMINS = ( 14 | ('Zachary Voase', 'zacharyvoase@me.com'), 15 | ) 16 | 17 | MANAGERS = ADMINS 18 | 19 | DATABASE_ENGINE = 'sqlite3' 20 | DATABASE_NAME = 'dev.sqlite3' 21 | DATABASE_USER = '' 22 | DATABASE_PASSWORD = '' 23 | DATABASE_HOST = '' 24 | DATABASE_PORT = '' 25 | 26 | # Local time zone for this installation. Choices can be found here: 27 | # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name 28 | # although not all choices may be available on all operating systems. 29 | # If running in a Windows environment this must be set to the same as your 30 | # system time zone. 31 | TIME_ZONE = 'Europe/London' 32 | 33 | # Language code for this installation. All choices can be found here: 34 | # http://www.i18nguy.com/unicode/language-identifiers.html 35 | LANGUAGE_CODE = 'en-gb' 36 | 37 | SITE_ID = 1 38 | 39 | # If you set this to False, Django will make some optimizations so as not 40 | # to load the internationalization machinery. 41 | USE_I18N = True 42 | 43 | # Absolute path to the directory that holds media. 44 | # Example: "/home/media/media.lawrence.com/" 45 | MEDIA_ROOT = '' 46 | 47 | # URL that handles the media served from MEDIA_ROOT. Make sure to use a 48 | # trailing slash if there is a path component (optional in other cases). 49 | # Examples: "http://media.lawrence.com", "http://example.com/media/" 50 | MEDIA_URL = '' 51 | 52 | # URL prefix for admin media -- CSS, JavaScript and images. Make sure to use a 53 | # trailing slash. 54 | # Examples: "http://foo.com/media/", "/media/". 55 | ADMIN_MEDIA_PREFIX = '/media/' 56 | 57 | # Make this unique, and don't share it with anybody. 58 | SECRET_KEY = '8@+k3lm3=s+ml6_*(cnpbg1w=6k9xpk5f=irs+&j4_6i=62fy^' 59 | 60 | # List of callables that know how to import templates from various sources. 61 | TEMPLATE_LOADERS = ( 62 | 'django.template.loaders.filesystem.load_template_source', 63 | 'django.template.loaders.app_directories.load_template_source', 64 | # 'django.template.loaders.eggs.load_template_source', 65 | ) 66 | 67 | MIDDLEWARE_CLASSES = ( 68 | 'django.middleware.common.CommonMiddleware', 69 | ) 70 | 71 | ROOT_URLCONF = 'example.urls' 72 | 73 | TEMPLATE_DIRS = ( 74 | # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates". 75 | # Always use forward slashes, even on Windows. 76 | # Don't forget to use absolute paths, not relative paths. 77 | ) 78 | 79 | INSTALLED_APPS = ( 80 | 'people', 81 | ) 82 | -------------------------------------------------------------------------------- /src/djqmixin/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | __version__ = '0.1' 4 | 5 | from django.db import models 6 | 7 | 8 | class QMixin(dict): 9 | 10 | """Abstract superclass for defining mixins.""" 11 | 12 | class __metaclass__(type): 13 | def __new__(mcls, name, bases, attrs): 14 | # Circumvent an error in the creation of `QMixin` itself. 15 | if bases == (dict,): 16 | return type.__new__(mcls, name, bases, attrs) 17 | 18 | # A `QMixin` subclass is transformed into a `QMixin` instance. 19 | return QMixin(attrs) 20 | 21 | def __repr__(self): 22 | return 'QMixin(%r)' % (dict(self),) 23 | 24 | 25 | class Manager(models.Manager): 26 | 27 | # If this is the default manager for a model, use this manager class for 28 | # relations (i.e. `group.people`, see README for details). 29 | use_for_related_fields = True 30 | 31 | class QuerySet(models.query.QuerySet): 32 | pass 33 | 34 | @classmethod 35 | def _with_qset_cls(cls, qset_cls): 36 | return type(cls.__name__, (cls,), {'QuerySet': qset_cls}) 37 | 38 | @classmethod 39 | def include(cls, *mixins): 40 | 41 | """ 42 | Create a new `Manager` class with the provided mixins. 43 | 44 | Call this method with one or more `QMixin` instances to return a new 45 | manager class. Don't forget to instantiate this afterwards, like so: 46 | 47 | objects = Manager.include(A, B, C)() 48 | 49 | `QMixin` instances can be easily created by subclassing `QMixin`; some 50 | metaclass hackery is used to achieve this: 51 | 52 | class AgeMixin(QMixin): 53 | def minors(self): 54 | return self.filter(age__lt=18) 55 | 56 | assert isinstance(AgeMixin, QMixin) 57 | 58 | If more than one mixin is supplied, they are combined into one. The 59 | behavior for conflicts is to resolve from left-to-right. For example: 60 | 61 | class A(QMixin): 62 | def method(self): 63 | return 'a' 64 | 65 | class B(QMixin): 66 | def method(self): 67 | return 'b' 68 | 69 | class Person(models.Model): 70 | objects = Manager.include(A, B)() 71 | 72 | assert Person.objects.method() == 'a' 73 | 74 | """ 75 | 76 | mixin = merge_mixins(mixins) 77 | # Create a new QuerySet class, inheriting from the current one, with the 78 | # 79 | qset_cls = type('QuerySet', (cls.QuerySet,), mixin) 80 | return cls._with_qset_cls(qset_cls) 81 | 82 | def get_query_set(self, *args, **kwargs): 83 | return self.QuerySet(model=self.model) 84 | 85 | def __getattr__(self, attr): 86 | try: 87 | return getattr(self.get_query_set(), attr) 88 | except AttributeError: 89 | raise 90 | 91 | 92 | 93 | def merge_mixins(mixins): 94 | 95 | """ 96 | Given a sequence of mixins, return a single, merged mixin. 97 | 98 | Resolution is from left-to-right, so due to the behavior of `dict.update()`, 99 | the sequence of mixins is reversed (using `sequence[::-1]`) and then a 100 | new mixin is `update()`d with each. 101 | """ 102 | 103 | if not mixins: 104 | raise ValueError("No mixins given") 105 | elif len(mixins) == 1: 106 | return mixins[0] 107 | 108 | combined = QMixin() 109 | for mixin in mixins[::-1]: 110 | combined.update(mixin) 111 | return combined 112 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `django-qmixin` 2 | 3 | `django-qmixin` is a reusable Django application for extending managers and the 4 | querysets they produce. 5 | 6 | A mixin is a subclass of `djqmixin.QMixin` which defines some related methods 7 | that operate on a `QuerySet` or `Manager` instance. Mixins can be ‘mixed in’ to 8 | a manager class, and their methods will be made available on all instances of 9 | that class (whether on the model itself or via a relation), as well as the 10 | `QuerySet` instances it produces. 11 | 12 | What this achieves is the ability to add queryset-level functionality to your 13 | model with the minimum amount of work possible. 14 | 15 | 16 | ## Mixins? Heresy! 17 | 18 | Well, not quite. `Manager.include()` doesn't monkey patch, and there's very 19 | little magic involved. Overall, there are only 38 lines of code in this library, 20 | and they're all quite heavily commented, so you can find out for yourself if you 21 | like. 22 | 23 | 24 | ## Installation 25 | 26 | The usual: 27 | 28 | easy_install django-qmixin # OR 29 | pip install django-qmixin 30 | 31 | The only other thing you'll need is Django itself; this library has been tested on versions 1.0 and 1.1. 32 | 33 | 34 | ## Usage 35 | 36 | Basic usage is as follows: 37 | 38 | from django.db import models 39 | from djqmixin import Manager, QMixin 40 | 41 | class AgeMixin(QMixin): 42 | def minors(self): 43 | return self.filter(age__lt=18) 44 | 45 | def adults(self): 46 | return self.filter(age__gte=18) 47 | 48 | class Group(models.Model): 49 | pass 50 | 51 | class Person(models.Model): 52 | GENDERS = dict(m='Male', f='Female', u='Unspecified').items() 53 | 54 | group = models.ForeignKey(Group, related_name='people') 55 | gender = models.CharField(max_length=1, choices=GENDERS) 56 | age = models.PositiveIntegerField() 57 | 58 | objects = Manager.include(AgeMixin)() 59 | 60 | # The `minors()` and `adults()` methods will be available on the manager: 61 | assert isinstance(Person.objects.minors(), models.query.QuerySet) 62 | 63 | # They'll be available on subsequent querysets: 64 | assert isinstance(Person.objects.filter(gender='m').minors(), 65 | models.query.QuerySet) 66 | 67 | # They'll also be available on relations, if they were mixed in to the 68 | # default manager for that model: 69 | group = Group.objects.all()[0] 70 | assert isinstance(group.people.minors(), models.query.QuerySet) 71 | 72 | A test project is located in `test/example/`; consult this for a more 73 | comprehensive example. 74 | 75 | 76 | ## (Un)license 77 | 78 | This is free and unencumbered software released into the public domain. 79 | 80 | Anyone is free to copy, modify, publish, use, compile, sell, or distribute this 81 | software, either in source code form or as a compiled binary, for any purpose, 82 | commercial or non-commercial, and by any means. 83 | 84 | In jurisdictions that recognize copyright laws, the author or authors of this 85 | software dedicate any and all copyright interest in the software to the public 86 | domain. We make this dedication for the benefit of the public at large and to 87 | the detriment of our heirs and successors. We intend this dedication to be an 88 | overt act of relinquishment in perpetuity of all present and future rights to 89 | this software under copyright law. 90 | 91 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 92 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 93 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE 94 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF 95 | CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 96 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 97 | 98 | For more information, please refer to 99 | -------------------------------------------------------------------------------- /test/example/people/fixtures/testing.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "fields": {}, 4 | "model": "people.group", 5 | "pk": 1 6 | }, 7 | { 8 | "fields": {}, 9 | "model": "people.group", 10 | "pk": 2 11 | }, 12 | { 13 | "fields": {}, 14 | "model": "people.group", 15 | "pk": 3 16 | }, 17 | { 18 | "fields": { 19 | "age": 17, 20 | "group": 1 21 | }, 22 | "model": "people.person", 23 | "pk": 1 24 | }, 25 | { 26 | "fields": { 27 | "age": 17, 28 | "group": 1 29 | }, 30 | "model": "people.person", 31 | "pk": 2 32 | }, 33 | { 34 | "fields": { 35 | "age": 17, 36 | "group": 1 37 | }, 38 | "model": "people.person", 39 | "pk": 3 40 | }, 41 | { 42 | "fields": { 43 | "age": 18, 44 | "group": 1 45 | }, 46 | "model": "people.person", 47 | "pk": 4 48 | }, 49 | { 50 | "fields": { 51 | "age": 18, 52 | "group": 1 53 | }, 54 | "model": "people.person", 55 | "pk": 5 56 | }, 57 | { 58 | "fields": { 59 | "age": 18, 60 | "group": 1 61 | }, 62 | "model": "people.person", 63 | "pk": 6 64 | }, 65 | { 66 | "fields": { 67 | "age": 19, 68 | "group": 1 69 | }, 70 | "model": "people.person", 71 | "pk": 7 72 | }, 73 | { 74 | "fields": { 75 | "age": 19, 76 | "group": 1 77 | }, 78 | "model": "people.person", 79 | "pk": 8 80 | }, 81 | { 82 | "fields": { 83 | "age": 19, 84 | "group": 1 85 | }, 86 | "model": "people.person", 87 | "pk": 9 88 | }, 89 | { 90 | "fields": { 91 | "age": 17, 92 | "group": 2 93 | }, 94 | "model": "people.person", 95 | "pk": 10 96 | }, 97 | { 98 | "fields": { 99 | "age": 17, 100 | "group": 2 101 | }, 102 | "model": "people.person", 103 | "pk": 11 104 | }, 105 | { 106 | "fields": { 107 | "age": 17, 108 | "group": 2 109 | }, 110 | "model": "people.person", 111 | "pk": 12 112 | }, 113 | { 114 | "fields": { 115 | "age": 18, 116 | "group": 2 117 | }, 118 | "model": "people.person", 119 | "pk": 13 120 | }, 121 | { 122 | "fields": { 123 | "age": 18, 124 | "group": 2 125 | }, 126 | "model": "people.person", 127 | "pk": 14 128 | }, 129 | { 130 | "fields": { 131 | "age": 18, 132 | "group": 2 133 | }, 134 | "model": "people.person", 135 | "pk": 15 136 | }, 137 | { 138 | "fields": { 139 | "age": 19, 140 | "group": 2 141 | }, 142 | "model": "people.person", 143 | "pk": 16 144 | }, 145 | { 146 | "fields": { 147 | "age": 19, 148 | "group": 2 149 | }, 150 | "model": "people.person", 151 | "pk": 17 152 | }, 153 | { 154 | "fields": { 155 | "age": 19, 156 | "group": 2 157 | }, 158 | "model": "people.person", 159 | "pk": 18 160 | }, 161 | { 162 | "fields": { 163 | "age": 17, 164 | "group": 3 165 | }, 166 | "model": "people.person", 167 | "pk": 19 168 | }, 169 | { 170 | "fields": { 171 | "age": 17, 172 | "group": 3 173 | }, 174 | "model": "people.person", 175 | "pk": 20 176 | }, 177 | { 178 | "fields": { 179 | "age": 17, 180 | "group": 3 181 | }, 182 | "model": "people.person", 183 | "pk": 21 184 | }, 185 | { 186 | "fields": { 187 | "age": 18, 188 | "group": 3 189 | }, 190 | "model": "people.person", 191 | "pk": 22 192 | }, 193 | { 194 | "fields": { 195 | "age": 18, 196 | "group": 3 197 | }, 198 | "model": "people.person", 199 | "pk": 23 200 | }, 201 | { 202 | "fields": { 203 | "age": 18, 204 | "group": 3 205 | }, 206 | "model": "people.person", 207 | "pk": 24 208 | }, 209 | { 210 | "fields": { 211 | "age": 19, 212 | "group": 3 213 | }, 214 | "model": "people.person", 215 | "pk": 25 216 | }, 217 | { 218 | "fields": { 219 | "age": 19, 220 | "group": 3 221 | }, 222 | "model": "people.person", 223 | "pk": 26 224 | }, 225 | { 226 | "fields": { 227 | "age": 19, 228 | "group": 3 229 | }, 230 | "model": "people.person", 231 | "pk": 27 232 | } 233 | ] 234 | -------------------------------------------------------------------------------- /distribute_setup.py: -------------------------------------------------------------------------------- 1 | #!python 2 | """Bootstrap distribute installation 3 | 4 | If you want to use setuptools in your package's setup.py, just include this 5 | file in the same directory with it, and add this to the top of your setup.py:: 6 | 7 | from distribute_setup import use_setuptools 8 | use_setuptools() 9 | 10 | If you want to require a specific version of setuptools, set a download 11 | mirror, or use an alternate download directory, you can do so by supplying 12 | the appropriate options to ``use_setuptools()``. 13 | 14 | This file can also be run as a script to install or upgrade setuptools. 15 | """ 16 | import os 17 | import sys 18 | import time 19 | import fnmatch 20 | import tempfile 21 | import tarfile 22 | from distutils import log 23 | 24 | try: 25 | from site import USER_SITE 26 | except ImportError: 27 | USER_SITE = None 28 | 29 | try: 30 | import subprocess 31 | 32 | def _python_cmd(*args): 33 | args = (sys.executable,) + args 34 | return subprocess.call(args) == 0 35 | 36 | except ImportError: 37 | # will be used for python 2.3 38 | def _python_cmd(*args): 39 | args = (sys.executable,) + args 40 | # quoting arguments if windows 41 | if sys.platform == 'win32': 42 | def quote(arg): 43 | if ' ' in arg: 44 | return '"%s"' % arg 45 | return arg 46 | args = [quote(arg) for arg in args] 47 | return os.spawnl(os.P_WAIT, sys.executable, *args) == 0 48 | 49 | DEFAULT_VERSION = "0.6.10" 50 | DEFAULT_URL = "http://pypi.python.org/packages/source/d/distribute/" 51 | SETUPTOOLS_FAKED_VERSION = "0.6c11" 52 | 53 | SETUPTOOLS_PKG_INFO = """\ 54 | Metadata-Version: 1.0 55 | Name: setuptools 56 | Version: %s 57 | Summary: xxxx 58 | Home-page: xxx 59 | Author: xxx 60 | Author-email: xxx 61 | License: xxx 62 | Description: xxx 63 | """ % SETUPTOOLS_FAKED_VERSION 64 | 65 | 66 | def _install(tarball): 67 | # extracting the tarball 68 | tmpdir = tempfile.mkdtemp() 69 | log.warn('Extracting in %s', tmpdir) 70 | old_wd = os.getcwd() 71 | try: 72 | os.chdir(tmpdir) 73 | tar = tarfile.open(tarball) 74 | _extractall(tar) 75 | tar.close() 76 | 77 | # going in the directory 78 | subdir = os.path.join(tmpdir, os.listdir(tmpdir)[0]) 79 | os.chdir(subdir) 80 | log.warn('Now working in %s', subdir) 81 | 82 | # installing 83 | log.warn('Installing Distribute') 84 | if not _python_cmd('setup.py', 'install'): 85 | log.warn('Something went wrong during the installation.') 86 | log.warn('See the error message above.') 87 | finally: 88 | os.chdir(old_wd) 89 | 90 | 91 | def _build_egg(egg, tarball, to_dir): 92 | # extracting the tarball 93 | tmpdir = tempfile.mkdtemp() 94 | log.warn('Extracting in %s', tmpdir) 95 | old_wd = os.getcwd() 96 | try: 97 | os.chdir(tmpdir) 98 | tar = tarfile.open(tarball) 99 | _extractall(tar) 100 | tar.close() 101 | 102 | # going in the directory 103 | subdir = os.path.join(tmpdir, os.listdir(tmpdir)[0]) 104 | os.chdir(subdir) 105 | log.warn('Now working in %s', subdir) 106 | 107 | # building an egg 108 | log.warn('Building a Distribute egg in %s', to_dir) 109 | _python_cmd('setup.py', '-q', 'bdist_egg', '--dist-dir', to_dir) 110 | 111 | finally: 112 | os.chdir(old_wd) 113 | # returning the result 114 | log.warn(egg) 115 | if not os.path.exists(egg): 116 | raise IOError('Could not build the egg.') 117 | 118 | 119 | def _do_download(version, download_base, to_dir, download_delay): 120 | egg = os.path.join(to_dir, 'distribute-%s-py%d.%d.egg' 121 | % (version, sys.version_info[0], sys.version_info[1])) 122 | if not os.path.exists(egg): 123 | tarball = download_setuptools(version, download_base, 124 | to_dir, download_delay) 125 | _build_egg(egg, tarball, to_dir) 126 | sys.path.insert(0, egg) 127 | import setuptools 128 | setuptools.bootstrap_install_from = egg 129 | 130 | 131 | def use_setuptools(version=DEFAULT_VERSION, download_base=DEFAULT_URL, 132 | to_dir=os.curdir, download_delay=15, no_fake=True): 133 | # making sure we use the absolute path 134 | to_dir = os.path.abspath(to_dir) 135 | was_imported = 'pkg_resources' in sys.modules or \ 136 | 'setuptools' in sys.modules 137 | try: 138 | try: 139 | import pkg_resources 140 | if not hasattr(pkg_resources, '_distribute'): 141 | if not no_fake: 142 | _fake_setuptools() 143 | raise ImportError 144 | except ImportError: 145 | return _do_download(version, download_base, to_dir, download_delay) 146 | try: 147 | pkg_resources.require("distribute>="+version) 148 | return 149 | except pkg_resources.VersionConflict: 150 | e = sys.exc_info()[1] 151 | if was_imported: 152 | sys.stderr.write( 153 | "The required version of distribute (>=%s) is not available,\n" 154 | "and can't be installed while this script is running. Please\n" 155 | "install a more recent version first, using\n" 156 | "'easy_install -U distribute'." 157 | "\n\n(Currently using %r)\n" % (version, e.args[0])) 158 | sys.exit(2) 159 | else: 160 | del pkg_resources, sys.modules['pkg_resources'] # reload ok 161 | return _do_download(version, download_base, to_dir, 162 | download_delay) 163 | except pkg_resources.DistributionNotFound: 164 | return _do_download(version, download_base, to_dir, 165 | download_delay) 166 | finally: 167 | if not no_fake: 168 | _create_fake_setuptools_pkg_info(to_dir) 169 | 170 | def download_setuptools(version=DEFAULT_VERSION, download_base=DEFAULT_URL, 171 | to_dir=os.curdir, delay=15): 172 | """Download distribute from a specified location and return its filename 173 | 174 | `version` should be a valid distribute version number that is available 175 | as an egg for download under the `download_base` URL (which should end 176 | with a '/'). `to_dir` is the directory where the egg will be downloaded. 177 | `delay` is the number of seconds to pause before an actual download 178 | attempt. 179 | """ 180 | # making sure we use the absolute path 181 | to_dir = os.path.abspath(to_dir) 182 | try: 183 | from urllib.request import urlopen 184 | except ImportError: 185 | from urllib2 import urlopen 186 | tgz_name = "distribute-%s.tar.gz" % version 187 | url = download_base + tgz_name 188 | saveto = os.path.join(to_dir, tgz_name) 189 | src = dst = None 190 | if not os.path.exists(saveto): # Avoid repeated downloads 191 | try: 192 | log.warn("Downloading %s", url) 193 | src = urlopen(url) 194 | # Read/write all in one block, so we don't create a corrupt file 195 | # if the download is interrupted. 196 | data = src.read() 197 | dst = open(saveto, "wb") 198 | dst.write(data) 199 | finally: 200 | if src: 201 | src.close() 202 | if dst: 203 | dst.close() 204 | return os.path.realpath(saveto) 205 | 206 | 207 | def _patch_file(path, content): 208 | """Will backup the file then patch it""" 209 | existing_content = open(path).read() 210 | if existing_content == content: 211 | # already patched 212 | log.warn('Already patched.') 213 | return False 214 | log.warn('Patching...') 215 | _rename_path(path) 216 | f = open(path, 'w') 217 | try: 218 | f.write(content) 219 | finally: 220 | f.close() 221 | return True 222 | 223 | 224 | def _same_content(path, content): 225 | return open(path).read() == content 226 | 227 | def _no_sandbox(function): 228 | def __no_sandbox(*args, **kw): 229 | try: 230 | from setuptools.sandbox import DirectorySandbox 231 | def violation(*args): 232 | pass 233 | DirectorySandbox._old = DirectorySandbox._violation 234 | DirectorySandbox._violation = violation 235 | patched = True 236 | except ImportError: 237 | patched = False 238 | 239 | try: 240 | return function(*args, **kw) 241 | finally: 242 | if patched: 243 | DirectorySandbox._violation = DirectorySandbox._old 244 | del DirectorySandbox._old 245 | 246 | return __no_sandbox 247 | 248 | @_no_sandbox 249 | def _rename_path(path): 250 | new_name = path + '.OLD.%s' % time.time() 251 | log.warn('Renaming %s into %s', path, new_name) 252 | os.rename(path, new_name) 253 | return new_name 254 | 255 | def _remove_flat_installation(placeholder): 256 | if not os.path.isdir(placeholder): 257 | log.warn('Unkown installation at %s', placeholder) 258 | return False 259 | found = False 260 | for file in os.listdir(placeholder): 261 | if fnmatch.fnmatch(file, 'setuptools*.egg-info'): 262 | found = True 263 | break 264 | if not found: 265 | log.warn('Could not locate setuptools*.egg-info') 266 | return 267 | 268 | log.warn('Removing elements out of the way...') 269 | pkg_info = os.path.join(placeholder, file) 270 | if os.path.isdir(pkg_info): 271 | patched = _patch_egg_dir(pkg_info) 272 | else: 273 | patched = _patch_file(pkg_info, SETUPTOOLS_PKG_INFO) 274 | 275 | if not patched: 276 | log.warn('%s already patched.', pkg_info) 277 | return False 278 | # now let's move the files out of the way 279 | for element in ('setuptools', 'pkg_resources.py', 'site.py'): 280 | element = os.path.join(placeholder, element) 281 | if os.path.exists(element): 282 | _rename_path(element) 283 | else: 284 | log.warn('Could not find the %s element of the ' 285 | 'Setuptools distribution', element) 286 | return True 287 | 288 | 289 | def _after_install(dist): 290 | log.warn('After install bootstrap.') 291 | placeholder = dist.get_command_obj('install').install_purelib 292 | _create_fake_setuptools_pkg_info(placeholder) 293 | 294 | @_no_sandbox 295 | def _create_fake_setuptools_pkg_info(placeholder): 296 | if not placeholder or not os.path.exists(placeholder): 297 | log.warn('Could not find the install location') 298 | return 299 | pyver = '%s.%s' % (sys.version_info[0], sys.version_info[1]) 300 | setuptools_file = 'setuptools-%s-py%s.egg-info' % \ 301 | (SETUPTOOLS_FAKED_VERSION, pyver) 302 | pkg_info = os.path.join(placeholder, setuptools_file) 303 | if os.path.exists(pkg_info): 304 | log.warn('%s already exists', pkg_info) 305 | return 306 | 307 | log.warn('Creating %s', pkg_info) 308 | f = open(pkg_info, 'w') 309 | try: 310 | f.write(SETUPTOOLS_PKG_INFO) 311 | finally: 312 | f.close() 313 | 314 | pth_file = os.path.join(placeholder, 'setuptools.pth') 315 | log.warn('Creating %s', pth_file) 316 | f = open(pth_file, 'w') 317 | try: 318 | f.write(os.path.join(os.curdir, setuptools_file)) 319 | finally: 320 | f.close() 321 | 322 | def _patch_egg_dir(path): 323 | # let's check if it's already patched 324 | pkg_info = os.path.join(path, 'EGG-INFO', 'PKG-INFO') 325 | if os.path.exists(pkg_info): 326 | if _same_content(pkg_info, SETUPTOOLS_PKG_INFO): 327 | log.warn('%s already patched.', pkg_info) 328 | return False 329 | _rename_path(path) 330 | os.mkdir(path) 331 | os.mkdir(os.path.join(path, 'EGG-INFO')) 332 | pkg_info = os.path.join(path, 'EGG-INFO', 'PKG-INFO') 333 | f = open(pkg_info, 'w') 334 | try: 335 | f.write(SETUPTOOLS_PKG_INFO) 336 | finally: 337 | f.close() 338 | return True 339 | 340 | 341 | def _before_install(): 342 | log.warn('Before install bootstrap.') 343 | _fake_setuptools() 344 | 345 | 346 | def _under_prefix(location): 347 | if 'install' not in sys.argv: 348 | return True 349 | args = sys.argv[sys.argv.index('install')+1:] 350 | for index, arg in enumerate(args): 351 | for option in ('--root', '--prefix'): 352 | if arg.startswith('%s=' % option): 353 | top_dir = arg.split('root=')[-1] 354 | return location.startswith(top_dir) 355 | elif arg == option: 356 | if len(args) > index: 357 | top_dir = args[index+1] 358 | return location.startswith(top_dir) 359 | elif option == '--user' and USER_SITE is not None: 360 | return location.startswith(USER_SITE) 361 | return True 362 | 363 | 364 | def _fake_setuptools(): 365 | log.warn('Scanning installed packages') 366 | try: 367 | import pkg_resources 368 | except ImportError: 369 | # we're cool 370 | log.warn('Setuptools or Distribute does not seem to be installed.') 371 | return 372 | ws = pkg_resources.working_set 373 | try: 374 | setuptools_dist = ws.find(pkg_resources.Requirement.parse('setuptools', 375 | replacement=False)) 376 | except TypeError: 377 | # old distribute API 378 | setuptools_dist = ws.find(pkg_resources.Requirement.parse('setuptools')) 379 | 380 | if setuptools_dist is None: 381 | log.warn('No setuptools distribution found') 382 | return 383 | # detecting if it was already faked 384 | setuptools_location = setuptools_dist.location 385 | log.warn('Setuptools installation detected at %s', setuptools_location) 386 | 387 | # if --root or --preix was provided, and if 388 | # setuptools is not located in them, we don't patch it 389 | if not _under_prefix(setuptools_location): 390 | log.warn('Not patching, --root or --prefix is installing Distribute' 391 | ' in another location') 392 | return 393 | 394 | # let's see if its an egg 395 | if not setuptools_location.endswith('.egg'): 396 | log.warn('Non-egg installation') 397 | res = _remove_flat_installation(setuptools_location) 398 | if not res: 399 | return 400 | else: 401 | log.warn('Egg installation') 402 | pkg_info = os.path.join(setuptools_location, 'EGG-INFO', 'PKG-INFO') 403 | if (os.path.exists(pkg_info) and 404 | _same_content(pkg_info, SETUPTOOLS_PKG_INFO)): 405 | log.warn('Already patched.') 406 | return 407 | log.warn('Patching...') 408 | # let's create a fake egg replacing setuptools one 409 | res = _patch_egg_dir(setuptools_location) 410 | if not res: 411 | return 412 | log.warn('Patched done.') 413 | _relaunch() 414 | 415 | 416 | def _relaunch(): 417 | log.warn('Relaunching...') 418 | # we have to relaunch the process 419 | args = [sys.executable] + sys.argv 420 | sys.exit(subprocess.call(args)) 421 | 422 | 423 | def _extractall(self, path=".", members=None): 424 | """Extract all members from the archive to the current working 425 | directory and set owner, modification time and permissions on 426 | directories afterwards. `path' specifies a different directory 427 | to extract to. `members' is optional and must be a subset of the 428 | list returned by getmembers(). 429 | """ 430 | import copy 431 | import operator 432 | from tarfile import ExtractError 433 | directories = [] 434 | 435 | if members is None: 436 | members = self 437 | 438 | for tarinfo in members: 439 | if tarinfo.isdir(): 440 | # Extract directories with a safe mode. 441 | directories.append(tarinfo) 442 | tarinfo = copy.copy(tarinfo) 443 | tarinfo.mode = 448 # decimal for oct 0700 444 | self.extract(tarinfo, path) 445 | 446 | # Reverse sort directories. 447 | if sys.version_info < (2, 4): 448 | def sorter(dir1, dir2): 449 | return cmp(dir1.name, dir2.name) 450 | directories.sort(sorter) 451 | directories.reverse() 452 | else: 453 | directories.sort(key=operator.attrgetter('name'), reverse=True) 454 | 455 | # Set correct owner, mtime and filemode on directories. 456 | for tarinfo in directories: 457 | dirpath = os.path.join(path, tarinfo.name) 458 | try: 459 | self.chown(tarinfo, dirpath) 460 | self.utime(tarinfo, dirpath) 461 | self.chmod(tarinfo, dirpath) 462 | except ExtractError: 463 | e = sys.exc_info()[1] 464 | if self.errorlevel > 1: 465 | raise 466 | else: 467 | self._dbg(1, "tarfile: %s" % e) 468 | 469 | 470 | def main(argv, version=DEFAULT_VERSION): 471 | """Install or upgrade setuptools and EasyInstall""" 472 | tarball = download_setuptools() 473 | _install(tarball) 474 | 475 | 476 | if __name__ == '__main__': 477 | main(sys.argv[1:]) 478 | --------------------------------------------------------------------------------