├── 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 |
--------------------------------------------------------------------------------