├── example ├── __init__.py ├── users │ ├── __init__.py │ ├── templates │ │ └── auth │ │ │ ├── account │ │ │ └── show.html │ │ │ └── user │ │ │ ├── show.html │ │ │ ├── new.html │ │ │ ├── index.html │ │ │ └── edit.html │ ├── tests │ │ ├── __init__.py │ │ ├── test_decoration.py │ │ ├── test_rendering.py │ │ ├── test_integration.py │ │ └── test_routing.py │ ├── models.py │ └── resources.py ├── templates │ └── base.html ├── manage.py ├── urls.py └── settings.py ├── REQUIREMENTS.test ├── REQUIREMENTS ├── nose.cfg ├── MANIFEST.in ├── .gitignore ├── doc ├── .markdoc.yaml ├── .templates │ └── base.html ├── reference.md ├── tutorial.md ├── resources.md ├── index.md ├── renderer.md └── uris.md ├── src └── dagny │ ├── urls │ ├── __init__.py │ ├── rails.py │ ├── atompub.py │ ├── router.py │ └── styles.py │ ├── __init__.py │ ├── utils.py │ ├── conneg.py │ ├── renderers.py │ ├── resource.py │ ├── action.py │ └── renderer.py ├── UNLICENSE ├── setup.py ├── README.md └── distribute_setup.py /example/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/users/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/users/templates/auth/account/show.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /REQUIREMENTS.test: -------------------------------------------------------------------------------- 1 | nose==1.1.2 2 | simplejson>=2.1.1 3 | -------------------------------------------------------------------------------- /REQUIREMENTS: -------------------------------------------------------------------------------- 1 | WebOb==1.2b2 2 | odict==1.4.3 3 | django-clsview==0.0.3 4 | -------------------------------------------------------------------------------- /nose.cfg: -------------------------------------------------------------------------------- 1 | [nosetests] 2 | with-doctest = yes 3 | doctest-tests = yes 4 | doctest-extension = doctest 5 | doctest-fixtures = _fixture 6 | where=src 7 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include distribute_setup.py 2 | include nose.cfg 3 | include REQUIREMENTS 4 | include REQUIREMENTS.* 5 | include README.md 6 | include UNLICENSE 7 | -------------------------------------------------------------------------------- /example/users/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from test_decoration import * 2 | from test_integration import * 3 | from test_rendering import * 4 | from test_routing import * 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.pyc 3 | *.pyo 4 | *.sqlite3 5 | .DS_Store 6 | .virtualenv 7 | MANIFEST 8 | build 9 | dist 10 | doc/.html 11 | doc/.tmp 12 | /distribute-*.tar.gz 13 | /distribute-*.egg 14 | -------------------------------------------------------------------------------- /example/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Dagny Example App 5 | 6 | 7 | {% block body %} 8 | {% endblock %} 9 | 10 | 11 | -------------------------------------------------------------------------------- /doc/.markdoc.yaml: -------------------------------------------------------------------------------- 1 | wiki-name: Dagny Documentation 2 | 3 | wiki-dir: "." 4 | static-dir: ".static" 5 | 6 | markdown: 7 | extensions: 8 | - codehilite 9 | - def_list 10 | - headerid 11 | - toc 12 | - tables 13 | -------------------------------------------------------------------------------- /src/dagny/urls/__init__.py: -------------------------------------------------------------------------------- 1 | import styles 2 | import router 3 | 4 | __all__ = ['resources', 'resource'] 5 | 6 | _router = router.URLRouter(style=styles.DjangoURLStyle()) 7 | resources = _router.resources 8 | resource = _router.resource 9 | -------------------------------------------------------------------------------- /src/dagny/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | from dagny.action import Action as action 5 | from dagny.resource import Resource 6 | import dagny.renderers 7 | 8 | __all__ = ['Resource', 'action'] 9 | 10 | __version__ = '0.3.0' 11 | -------------------------------------------------------------------------------- /src/dagny/urls/rails.py: -------------------------------------------------------------------------------- 1 | import styles 2 | import router 3 | 4 | 5 | __all__ = ['resources', 'resource'] 6 | 7 | 8 | _router = router.URLRouter(style=styles.RailsURLStyle()) 9 | resources = _router.resources 10 | resource = _router.resource 11 | -------------------------------------------------------------------------------- /src/dagny/urls/atompub.py: -------------------------------------------------------------------------------- 1 | import styles 2 | import router 3 | 4 | 5 | __all__ = ['resources', 'resource'] 6 | 7 | 8 | _router = router.URLRouter(style=styles.AtomPubURLStyle()) 9 | resources = _router.resources 10 | resource = _router.resource 11 | -------------------------------------------------------------------------------- /example/users/templates/auth/user/show.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block body %} 4 |

Username: {{ self.user.username }}

5 |

First name: {{ self.user.first_name }}

6 |

Last name: {{ self.user.last_name }}

7 |

Edit

8 | {% endblock %} 9 | -------------------------------------------------------------------------------- /example/users/templates/auth/user/new.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block body %} 4 |
5 | {% csrf_token %} 6 | 7 | {{ self.form.errors.as_ul }} 8 | {{ self.form.as_ul }} 9 | 10 | 11 |
12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /doc/.templates/base.html: -------------------------------------------------------------------------------- 1 | {% extends "markdoc-default/base.html" %} 2 | 3 | {% block body %} 4 | 5 | Fork me on GitHub 6 | 7 | {{ super() }} 8 | {% endblock %} 9 | -------------------------------------------------------------------------------- /example/users/templates/auth/user/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block body %} 4 | 9 | 10 |

11 | Sign Up! 12 |

13 | {% endblock %} 14 | -------------------------------------------------------------------------------- /example/users/models.py: -------------------------------------------------------------------------------- 1 | ## Empty files deserve poetry. 2 | 3 | # She dwelt among the untrodden ways 4 | # Beside the springs of Dove, 5 | # A Maid whom there were none to praise 6 | # And very few to love: 7 | # 8 | # A violet by a mossy stone 9 | # Half hidden from the eye! 10 | # Fair as a star, when only one 11 | # Is shining in the sky. 12 | # 13 | # She lived unknown, and few could know 14 | # When Lucy ceased to be; 15 | # But she is in her grave, and oh, 16 | # The difference to me! 17 | # 18 | # -- William Wordsworth 19 | -------------------------------------------------------------------------------- /example/users/templates/auth/user/edit.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block body %} 4 |
5 | {% csrf_token %} 6 | 7 | {{ self.form.errors.as_ul }} 8 | {{ self.form.as_ul }} 9 | 10 | 11 |
12 | 13 |
14 | {% csrf_token %} 15 | 16 | 17 | 18 | 19 |
20 | {% endblock %} 21 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /example/urls.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from dagny.urls import resources, resource, rails, atompub 4 | from django.conf.urls.defaults import * 5 | 6 | from django.contrib import admin 7 | admin.autodiscover() 8 | 9 | urlpatterns = patterns('', 10 | (r'^users/', resources('users.resources.User', name='User')), 11 | 12 | # Stub routes for the routing tests. 13 | (r'^users-atompub/', atompub.resources('users.resources.User', 14 | name='UserAtomPub')), 15 | (r'^users-rails', rails.resources('users.resources.User', 16 | name='UserRails')), 17 | 18 | (r'^account/', resource('users.resources.Account', name='Account')), 19 | (r'^account-atompub/', atompub.resource('users.resources.Account', 20 | name='AccountAtomPub')), 21 | (r'^account-rails', rails.resource('users.resources.Account', 22 | name='AccountRails')), 23 | 24 | (r'^admin/', include(admin.site.urls)), 25 | ) 26 | -------------------------------------------------------------------------------- /src/dagny/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import re 4 | 5 | 6 | def camel_to_underscore(camel_string): 7 | 8 | """ 9 | Convert a CamelCase string to under_score. 10 | 11 | Examples: 12 | 13 | >>> camel_to_underscore('SplitAtTheBoundaries') 14 | 'split_at_the_boundaries' 15 | 16 | >>> camel_to_underscore('XYZResource') 17 | 'xyz_resource' 18 | 19 | >>> camel_to_underscore('ResourceXYZ') 20 | 'resource_xyz' 21 | 22 | >>> camel_to_underscore('XYZ') 23 | 'xyz' 24 | 25 | """ 26 | 27 | return re.sub( 28 | r'(((?<=[a-z])[A-Z])|([A-Z](?![A-Z]|$)))', r'_\1', 29 | camel_string).lower().strip('_') 30 | 31 | 32 | def resource_name(resource_cls): 33 | """Return the name of a given resource, stripping 'Resource' off the end.""" 34 | 35 | from dagny import Resource 36 | 37 | if isinstance(resource_cls, Resource): 38 | resource_cls = resource_cls.__class__ 39 | 40 | name = resource_cls.__name__ 41 | if name.endswith('Resource'): 42 | return name[:-8] 43 | return name 44 | -------------------------------------------------------------------------------- /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 25 | -------------------------------------------------------------------------------- /doc/reference.md: -------------------------------------------------------------------------------- 1 | # Reference 2 | 3 | ## Fundamental Concepts 4 | 5 | Dagny’s fundamental unit is the **resource**. A resource is identified by, and 6 | can be accessed at, a single HTTP URI. A resource may be singular or plural; for 7 | example, `/users` is a collection of users, and `/users/31215` is a single user. 8 | See the [URI documentation](/uris) for a detailed schema on Dagny’s URIs and 9 | their behavior. 10 | 11 | In your code, resources are defined as classes with several **actions**. An 12 | action is essentially a method, routed to based on the request path and method, 13 | which is in charge of processing that particular request and returning a 14 | response. See the [Resources documentation](/resources) for a concrete 15 | explanation of how resources and actions are defined. 16 | 17 | Actions themselves are broken up into a main body, and a **renderer**. The body 18 | of the action does the processing—saves/retrieves a record, or perhaps performs 19 | some calculation. The renderer is responsible for producing a response. This 20 | split allows for modular content negotiation: you can define multiple 21 | **renderer backends** for an action, each of which is associated with a MIME 22 | type. These backends will be dispatched to based on what the client has 23 | requested; the default is to produce HTML, but you can easily write backends for 24 | JSON, XML, RDF, or even PNG if necessary. A full reference on the renderer 25 | system can be found in the [Renderer documentation](/renderer). 26 | -------------------------------------------------------------------------------- /example/users/tests/test_decoration.py: -------------------------------------------------------------------------------- 1 | import urlparse 2 | 3 | from django.conf import settings 4 | from django.contrib.auth.models import User 5 | from django.test import TestCase 6 | 7 | 8 | class DecoratorTest(TestCase): 9 | 10 | def test_login_required_when_logged_out_redirects_to_login_url(self): 11 | response = self.client.get('/account/') 12 | assert response.status_code == 302 13 | redirected_to = urlparse.urlparse(response['location']) 14 | assert redirected_to.path == settings.LOGIN_URL 15 | 16 | def test_login_required_when_logged_in_does_not_redirect(self): 17 | user = User.objects.create_user(username='someuser', 18 | email='someuser@example.com', 19 | password='password') 20 | assert self.client.login(username='someuser', password='password') 21 | 22 | response = self.client.get('/account/') 23 | assert response.status_code == 200 24 | assert response.templates[0].name == 'auth/account/show.html' 25 | 26 | def test_login_required_when_logged_in_dispatches_to_renderer_correctly(self): 27 | user = User.objects.create_user(username='someuser', 28 | email='someuser@example.com', 29 | password='password') 30 | assert self.client.login(username='someuser', password='password') 31 | 32 | response = self.client.get('/account/?format=json') 33 | assert response.status_code == 200 34 | assert response['content-type'] == 'application/json' 35 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import glob 5 | import os 6 | import re 7 | 8 | from distribute_setup import use_setuptools; use_setuptools() 9 | from setuptools import setup, find_packages 10 | 11 | 12 | rel_file = lambda *args: os.path.join(os.path.dirname(os.path.abspath(__file__)), *args) 13 | 14 | def read_from(filename): 15 | fp = open(filename) 16 | try: 17 | return fp.read() 18 | finally: 19 | fp.close() 20 | 21 | def lines_from(filename): 22 | return filter(None, map(lambda s: s.strip(), read_from(filename).splitlines())) 23 | 24 | def get_version(): 25 | data = read_from(rel_file('src', 'dagny', '__init__.py')) 26 | return re.search(r"__version__ = '([^']+)'", data).group(1) 27 | 28 | def get_requirements(): 29 | return lines_from(rel_file('REQUIREMENTS')) 30 | 31 | def get_extra_requirements(): 32 | extras_require = {} 33 | for req_filename in glob.glob(rel_file('REQUIREMENTS.*')): 34 | group = os.path.basename(req_filename).split('.', 1)[1] 35 | extras_require[group] = lines_from(req_filename) 36 | return extras_require 37 | 38 | 39 | setup( 40 | name = 'dagny', 41 | version = get_version(), 42 | author = "Zachary Voase", 43 | author_email = "z@zacharyvoase.com", 44 | url = 'http://github.com/zacharyvoase/dagny', 45 | description = "Rails-style Resource-Oriented Architecture for Django.", 46 | packages = find_packages(where='src'), 47 | package_dir = {'': 'src'}, 48 | install_requires = get_requirements(), 49 | extras_require = get_extra_requirements(), 50 | ) 51 | -------------------------------------------------------------------------------- /example/settings.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Django settings for example project. 3 | 4 | import os 5 | import sys 6 | 7 | DEBUG = True 8 | TEMPLATE_DEBUG = DEBUG 9 | 10 | ADMINS = ( 11 | ('Zachary Voase', 'z@zacharyvoase'), 12 | ) 13 | 14 | MANAGERS = ADMINS 15 | 16 | PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__)) 17 | sys.path.append(PROJECT_ROOT) 18 | 19 | DB_DIR = os.path.join(PROJECT_ROOT, 'db') 20 | if not os.path.exists(DB_DIR): 21 | os.makedirs(DB_DIR) 22 | 23 | DATABASES = { 24 | 'default': { 25 | 'ENGINE': 'django.db.backends.sqlite3', 26 | 'NAME': os.path.join(DB_DIR, 'development.sqlite3'), 27 | 'USER': '', 28 | 'PASSWORD': '', 29 | 'HOST': '', 30 | 'PORT': '', 31 | } 32 | } 33 | 34 | TIME_ZONE = 'GMT' 35 | LANGUAGE_CODE = 'en-us' 36 | SITE_ID = 1 37 | 38 | USE_I18N = False 39 | USE_L10N = True 40 | 41 | MEDIA_ROOT = os.path.join(PROJECT_ROOT, 'media') 42 | MEDIA_URL = '/media/' 43 | ADMIN_MEDIA_PREFIX = '/media/admin/' 44 | 45 | SECRET_KEY = 'c-!9fgws_aa5fyybk97da5xz63dxhuuxczlal76k!5d#i7vuo&' 46 | 47 | TEMPLATE_LOADERS = ( 48 | 'django.template.loaders.filesystem.Loader', 49 | 'django.template.loaders.app_directories.Loader', 50 | # 'django.template.loaders.eggs.Loader', 51 | ) 52 | 53 | MIDDLEWARE_CLASSES = ( 54 | 'django.middleware.common.CommonMiddleware', 55 | 'django.contrib.sessions.middleware.SessionMiddleware', 56 | 'django.middleware.csrf.CsrfViewMiddleware', 57 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 58 | 'django.contrib.messages.middleware.MessageMiddleware', 59 | ) 60 | 61 | ROOT_URLCONF = 'example.urls' 62 | 63 | TEMPLATE_DIRS = ( 64 | os.path.join(PROJECT_ROOT, 'templates'), 65 | ) 66 | 67 | INSTALLED_APPS = ( 68 | 'django.contrib.admin', 69 | 'django.contrib.auth', 70 | 'django.contrib.contenttypes', 71 | 'django.contrib.sessions', 72 | 'django.contrib.sites', 73 | 'django.contrib.messages', 74 | 75 | 'users', 76 | ) 77 | -------------------------------------------------------------------------------- /example/users/tests/test_rendering.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | 4 | def assert_content_type(response, expected): 5 | actual = response['content-type'].split(';', 1)[0] 6 | assert actual == expected, \ 7 | "Expected a Content-Type of %r, got %r" % (expected, actual) 8 | 9 | class SkipTest(TestCase): 10 | 11 | def test_quality(self): 12 | # Even though XML has a much higher quality score than JSON, because it 13 | # raises Skip it should never be processed. 14 | response = self.client.get("/users/", 15 | HTTP_ACCEPT=("application/xml;q=1," 16 | "application/json;q=0.1")) 17 | 18 | self.assertEqual(response.status_code, 200) 19 | assert_content_type(response, 'application/json') 20 | 21 | def test_html_fallback(self): 22 | # Because XML raises skip, even when it is the only acceptable response 23 | # type it should still cause the HTML fallback to be used. 24 | response = self.client.get("/users/", HTTP_ACCEPT="application/xml") 25 | 26 | self.assertEqual(response.status_code, 200) 27 | assert_content_type(response, 'text/html') 28 | 29 | 30 | class HTMLFallbackTest(TestCase): 31 | 32 | def test_undefined(self): 33 | # Asking for an undefined type should trigger the HTML fallback. 34 | response = self.client.get("/users/", HTTP_ACCEPT="image/png") 35 | 36 | self.assertEqual(response.status_code, 200) 37 | assert_content_type(response, 'text/html') 38 | 39 | def test_undefined_with_quality(self): 40 | # Asking for an undefined type with a higher quality than HTML should 41 | # produce an HTML response. 42 | response = self.client.get("/users/", 43 | HTTP_ACCEPT=("image/png;q=1.0," 44 | "text/html;q=0.1")) 45 | 46 | self.assertEqual(response.status_code, 200) 47 | assert_content_type(response, 'text/html') 48 | -------------------------------------------------------------------------------- /src/dagny/conneg.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Helpers and global mappings for content negotiation. 5 | 6 | If you want to define a custom mimetype shortcode, add it to the `MIMETYPES` 7 | dictionary in this module (without the leading '.' character). For example: 8 | 9 | from dagny.conneg import MIMETYPES 10 | 11 | MIMETYPES['png'] = 'image/png' 12 | MIMETYPES['json'] = 'text/javascript' 13 | 14 | """ 15 | 16 | import mimetypes 17 | 18 | from webob.acceptparse import MIMEAccept 19 | 20 | __all__ = ['MIMETYPES', 'match_accept'] 21 | 22 | 23 | # Maps renderer shortcodes => mimetypes. 24 | MIMETYPES = { 25 | 'rss': 'application/rss+xml', 26 | 'json': 'application/json', 27 | 'rdf_xml': 'application/rdf+xml', 28 | 'xhtml': 'application/xhtml+xml', 29 | 'xml': 'application/xml', 30 | } 31 | 32 | 33 | # Load all extension => mimetype mappings from `mimetypes` stdlib module. 34 | for ext, mimetype in mimetypes.types_map.iteritems(): 35 | shortcode = ext.lstrip(".").replace(".", "_") # .tar.bz2 => tar_bz2 36 | MIMETYPES.setdefault(shortcode, mimetype) 37 | del ext, shortcode, mimetype # Clean up 38 | 39 | 40 | def match_accept(header, shortcodes): 41 | 42 | """ 43 | Match an Accept header against a list of shortcodes, in order of preference. 44 | 45 | A few examples: 46 | 47 | >>> header = "application/xml,application/xhtml+xml,text/html" 48 | 49 | >>> match_accept(header, ['html', 'json', 'xml']) 50 | ['html', 'xml'] 51 | 52 | >>> header2 = "application/json,application/xml" 53 | 54 | >>> match_accept(header2, ['html', 'json', 'xml']) 55 | ['json', 'xml'] 56 | 57 | >>> match_accept(header2, ['html', 'xml', 'json']) 58 | ['xml', 'json'] 59 | 60 | """ 61 | 62 | server_types = map(MIMETYPES.__getitem__, shortcodes) 63 | client_types = list(MIMEAccept(header)) 64 | matches = [] 65 | for mimetype in server_types: 66 | if mimetype in client_types: 67 | matches.append(mimetype) 68 | 69 | return map(shortcodes.__getitem__, map(server_types.index, matches)) 70 | -------------------------------------------------------------------------------- /src/dagny/renderers.py: -------------------------------------------------------------------------------- 1 | """Generic, built-in renderers.""" 2 | 3 | from dagny.action import Action 4 | from dagny.utils import camel_to_underscore, resource_name 5 | 6 | 7 | @Action.RENDERER.html 8 | def render_html(action, resource, content_type=None, status=None, 9 | current_app=None): 10 | 11 | """ 12 | Render an appropriate HTML response for an action. 13 | 14 | This is a generic renderer backend which produces HTML responses. It uses 15 | the name of the resource and current action to generate a template name, 16 | then renders the template with a `RequestContext`. 17 | 18 | To retrieve the template name, the resource name is first turned from 19 | CamelCase to lowercase_underscore_separated; if the class name ends in 20 | `Resource`, this is first removed from the end. For example: 21 | 22 | User => user 23 | UserResource => user 24 | NameXYZ => name_xyz 25 | XYZName => xyz_name 26 | 27 | You can optionally define a template path prefix on your `Resource` like 28 | so: 29 | 30 | class User(Resource): 31 | template_path_prefix = 'auth/' 32 | # ... 33 | 34 | The template name is assembled from the template path prefix, the 35 | re-formatted resource name, and the current action name. So, for a `User` 36 | resource, with `template_path_prefix = 'auth/'`, and an action of `show`, 37 | the template name would be: 38 | 39 | auth/user/show.html 40 | 41 | Finally, this is rendered using `django.shortcuts.render()`. The resource 42 | is passed into the context as `self`, so that attribute assignments from 43 | the action will be available in the template. This also uses 44 | `RequestContext`, so configured context processors will also be available. 45 | """ 46 | 47 | from django.shortcuts import render 48 | 49 | resource_label = camel_to_underscore(resource_name(resource)) 50 | template_path_prefix = getattr(resource, 'template_path_prefix', "") 51 | template_name = "%s%s/%s.html" % (template_path_prefix, resource_label, 52 | action.name) 53 | 54 | return render(resource.request, template_name, {'self': resource}, 55 | content_type=content_type, status=status, 56 | current_app=current_app) 57 | -------------------------------------------------------------------------------- /example/users/resources.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from dagny import Resource, action 4 | from dagny.renderer import Skip 5 | from django.contrib.auth import forms, models 6 | from django.contrib.auth.decorators import login_required 7 | from django.http import HttpResponse 8 | from django.shortcuts import get_object_or_404, redirect 9 | import simplejson 10 | 11 | 12 | class User(Resource): 13 | 14 | template_path_prefix = 'auth/' 15 | 16 | @action 17 | def index(self): 18 | self.users = models.User.objects.all() 19 | 20 | @index.render.json 21 | def index(self): 22 | return json_response([user_to_dict(user) for user in self.users]) 23 | 24 | # Stub to test that skipping works. 25 | @index.render.xml 26 | def index(self): 27 | raise Skip 28 | 29 | @action 30 | def new(self): 31 | self.form = forms.UserCreationForm() 32 | 33 | @action 34 | def create(self): 35 | self.form = forms.UserCreationForm(self.request.POST) 36 | if self.form.is_valid(): 37 | self.user = self.form.save() 38 | return redirect('User#show', str(self.user.id)) 39 | 40 | return self.new.render(status=403) 41 | 42 | @action 43 | def show(self, user_id): 44 | self.user = get_object_or_404(models.User, id=int(user_id)) 45 | 46 | @show.render.json 47 | def show(self): 48 | return json_response(user_to_dict(self.user)) 49 | 50 | @action 51 | def edit(self, user_id): 52 | self.user = get_object_or_404(models.User, id=int(user_id)) 53 | self.form = forms.UserChangeForm(instance=self.user) 54 | 55 | @action 56 | def update(self, user_id): 57 | self.user = get_object_or_404(models.User, id=int(user_id)) 58 | self.form = forms.UserChangeForm(self.request.POST, instance=self.user) 59 | if self.form.is_valid(): 60 | self.form.save() 61 | return redirect('User#show', str(self.user.id)) 62 | 63 | return self.edit.render(status=403) 64 | 65 | @action 66 | def destroy(self, user_id): 67 | self.user = get_object_or_404(models.User, id=int(user_id)) 68 | self.user.delete() 69 | return redirect('User#index') 70 | 71 | 72 | # A stub resource for the routing tests. 73 | class Account(Resource): 74 | 75 | template_path_prefix = 'auth/' 76 | 77 | @action 78 | @action.deco(login_required) 79 | def show(self): 80 | return 81 | 82 | @show.render.json 83 | def show(self): 84 | return json_response({'username': self.request.user.username}) 85 | 86 | 87 | def json_response(data): 88 | return HttpResponse(content=simplejson.dumps(data), 89 | content_type='application/json') 90 | 91 | def user_to_dict(user): 92 | return { 93 | "username": user.username, 94 | "first_name": user.first_name, 95 | "last_name": user.last_name 96 | } 97 | -------------------------------------------------------------------------------- /doc/tutorial.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # The Dagny Tutorial 4 | 5 | In this tutorial, I’m going to walk through creating a project with 6 | authentication. The finished project will have signup, user pages, account 7 | settings management, login and logout. It should provide a good grounding in how 8 | to create resourceful web applications using Dagny. 9 | 10 | 11 | ## Setup 12 | 13 | Begin by installing Dagny: 14 | 15 | :::bash 16 | pip install dagny # or 17 | easy_install dagny 18 | 19 | If you aren’t using pip yet, [you should be][pip]. 20 | 21 | [pip]: http://python-distribute.org/pip_distribute.png 22 | 23 | Now create a fresh Django project: 24 | 25 | ::bash 26 | django-admin.py startproject tutorial 27 | cd tutorial/ 28 | chmod +x manage.py # So we can run ./manage.py 29 | mkdir templates/ # Project-wide templates will go here. 30 | 31 | You need to edit the `settings.py` file; I’ve provided a sample file 32 | [here](http://gist.github.com/444455), which you can drop into your project—just 33 | fill in the two missing values (`ADMINS` and `SECRET_KEY`). 34 | 35 | Set up the database and run a quick test: 36 | 37 | :::bash 38 | ./manage.py syncdb 39 | ./manage.py test 40 | 41 | You should see a few lines of output, ending in the following: 42 | 43 | :::text 44 | ---------------------------------------------------------------------- 45 | Ran 154 tests in 2.852s 46 | 47 | OK 48 | Destroying test database 'default'... 49 | 50 | That means everything worked. 51 | 52 | 53 | ## The first resource: Users 54 | 55 | Create an app called `users`: 56 | 57 | :::bash 58 | ./manage.py startapp users 59 | 60 | This app will manage user and user session resources—this encompasses listing, 61 | displaying, creating and editing users, and logging in and out. You’ll see, 62 | however, that what would normally take a lot of code and configuration is 63 | actually very simple to do with Dagny. 64 | 65 | Create and start editing a `users.resources` module: 66 | 67 | :::bash 68 | vim users/resources.py 69 | 70 | Add this basic structure: 71 | 72 | #!python 73 | from dagny import Resource, action 74 | 75 | class User(Resource): 76 | @action 77 | def index(self): 78 | pass 79 | 80 | @action 81 | def new(self): 82 | pass 83 | 84 | @action 85 | def create(self): 86 | pass 87 | 88 | @action 89 | def show(self, user_id): 90 | pass 91 | 92 | @action 93 | def edit(self, user_id): 94 | pass 95 | 96 | @action 97 | def update(self, user_id): 98 | pass 99 | 100 | @action 101 | def destroy(self, user_id): 102 | pass 103 | 104 | As you can see, we’ve stubbed out 7 methods on the `User` resource: `index`, 105 | `new`, `create`, `show`, `edit`, `update` and `destroy`. The expected behavior 106 | of each of these is described in depth in the [URI reference](/uris). 107 | 108 | 109 | -------------------------------------------------------------------------------- /src/dagny/resource.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django.http import Http404, HttpResponseNotAllowed 4 | from djclsview import View 5 | 6 | __all__ = ['Resource'] 7 | 8 | 9 | class Resource(View): 10 | 11 | def __init__(self, request, *args, **params): 12 | self.request = request 13 | self.args = args 14 | self.params = params 15 | self._called_yet = False 16 | 17 | def __call__(self): 18 | """Dispatch to an action based on HTTP method + URL.""" 19 | 20 | # The problem with defining a resource as a callable is that a 21 | # reference to `self` from a Django template (in v1.3) will attempt to 22 | # call the resource again. 23 | if self._called_yet: 24 | return self 25 | self._called_yet = True 26 | 27 | method = self.request.POST.get('_method', self.request.method).upper() 28 | try: 29 | method_action_map = self.params.pop('methods') 30 | except KeyError: 31 | raise ValueError("Expected 'methods' dict in view kwargs") 32 | return self._route(method, method_action_map)() 33 | 34 | def _route(self, method, method_action_map): 35 | 36 | """ 37 | Resolve an HTTP method and an HTTP method -> action mapping to a view. 38 | 39 | There are two sources for the list of 'defined methods' on a given URL: 40 | the HTTP method -> action map passed in to this method, and the actions 41 | which are defined on this `Resource` class. If the intersection of 42 | these two lists is empty--to wit, no methods are defined for the 43 | current URL--return a stub 404 view. Otherwise, if an HTTP method is 44 | sent which is not in *both* these lists, return a 405 'Not Allowed' 45 | view (which will contain the list of accepted methods at this URL). 46 | 47 | If the HTTP method sent is in the method -> action map, and the mapped 48 | action is defined on this `Resource`, return that action (which will be 49 | a callable `BoundAction` instance). 50 | """ 51 | 52 | allowed_methods = self._allowed_methods(method_action_map) 53 | if method not in allowed_methods: 54 | # If *no* methods are defined for this URL, return a 404. 55 | if not allowed_methods: 56 | return not_found 57 | return lambda: HttpResponseNotAllowed(allowed_methods) 58 | 59 | action_name = method_action_map[method] 60 | return getattr(self, action_name) 61 | 62 | def _allowed_methods(self, method_action_map): 63 | allowed_methods = [] 64 | for meth, action_name in method_action_map.items(): 65 | if hasattr(self, action_name): 66 | allowed_methods.append(meth) 67 | return allowed_methods 68 | 69 | def _format(self): 70 | """Return a mimetype shortcode, in case there's no Accept header.""" 71 | 72 | if self.params.get('format'): 73 | return self.params['format'].lstrip('.') 74 | return self.request.GET.get('format') 75 | 76 | 77 | def not_found(): 78 | """Stub function to raise `django.http.Http404`.""" 79 | 80 | raise Http404 81 | -------------------------------------------------------------------------------- /src/dagny/urls/router.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import defaults 2 | 3 | 4 | class URLRouter(object): 5 | 6 | """ 7 | Responsible for generating include()-able URLconfs for resources. 8 | 9 | Accepts only one argument on instantiation, `style`. This should be a 10 | callable which accepts an action parameter, a routing mode and an ID regex, 11 | and returns a regular expression with a route for that action. You should 12 | only need to use the styles already defined in `dagny.urls.styles`. 13 | """ 14 | 15 | def __init__(self, style): 16 | self.style = style 17 | 18 | def _make_patterns(self, resource_name, id, name, actions, urls): 19 | 20 | """ 21 | Construct an `include()` with all the URLs for a resource. 22 | 23 | :param resource_name: 24 | The full path to the resource (e.g. `myapp.resources.User`). 25 | :param id: 26 | The ID parameter, either as a regex fragment or a `(name, regex)` 27 | pair, which will normally be translated by the URL style to a named 28 | group in the URL. 29 | :param name: 30 | The name for this resource, which will be used to generate named 31 | URL patterns (e.g. if name is 'User', URLs will be 'User#index', 32 | 'User#show' etc.). If `None`, defaults to `resource_name`. 33 | :param actions: 34 | The actions to generate (named) routes for. If `None`, a route and 35 | a name will be generated for every one defined by the chosen URL 36 | style. 37 | :param urls: 38 | A list of the URLs to define patterns for. Must be made up only of 39 | 'member', 'collection', 'new', 'edit', 'singleton' and 40 | 'singleton_edit'. 41 | """ 42 | 43 | if actions is not None: 44 | actions = set(actions) 45 | if name is None: 46 | name = resource_name 47 | 48 | urlpatterns = [] 49 | for url in urls: 50 | # URLStyle.__call__(url_name, id_pattern) 51 | # => (url_pattern, {method: action, ...}) 52 | pattern, methods = self.style(url, id) 53 | # Filter methods dict to only contain the selected actions. 54 | methods = dict( 55 | (method, action) for method, action in methods.iteritems() 56 | if (actions is None) or (action in actions)) 57 | # Add named url patterns, one per action. Note that we will have 58 | # duplicate URLs in some cases, but this is so that 59 | # `{% url User#show %}` can be distinguished from 60 | # `{% url User#update %}` when it makes sense. 61 | for action in methods.itervalues(): 62 | urlpatterns.append(defaults.url(pattern, resource_name, 63 | kwargs={'methods': methods}, 64 | name=("%s#%s" % (name, action)))) 65 | return defaults.include(defaults.patterns('', *urlpatterns)) 66 | 67 | def resources(self, resource_name, id=r'\d+', actions=None, name=None): 68 | return self._make_patterns(resource_name, id, name, actions, 69 | ['collection', 'new', 'member', 'edit']) 70 | 71 | def resource(self, resource_name, actions=None, name=None): 72 | return self._make_patterns(resource_name, '', name, actions, 73 | ['singleton', 'new', 'singleton_edit']) 74 | -------------------------------------------------------------------------------- /doc/resources.md: -------------------------------------------------------------------------------- 1 | # Resources 2 | 3 | The **resource** is the most basic concept in writing RESTful applications. A 4 | resource is identified by a URI, and clients interact with resources 5 | using the standard HTTP methods. Detailed information on the URI schema and the 6 | behavior of resources over HTTP is provided in the [URI documentation](/uris). 7 | 8 | 9 | ## Defining Resources 10 | 11 | Resources are subclasses of `dagny.Resource`. Actions are methods on these 12 | subclasses, decorated with `@action`. Here’s a short example: 13 | 14 | #!python 15 | from dagny import Resource, action 16 | from django.shortcuts import get_object_or_404, redirect 17 | 18 | from django.contrib.auth import forms, models 19 | 20 | class User(Resource): 21 | 22 | @action 23 | def index(self): 24 | self.users = models.User.objects.all() 25 | 26 | @action 27 | def new(self): 28 | self.form = forms.UserCreationForm() 29 | 30 | @action 31 | def create(self): 32 | self.form = forms.UserCreationForm(request.POST) 33 | if self.form.is_valid(): 34 | self.user = self.form.save() 35 | return redirect(self.user) 36 | 37 | response = self.new.render() 38 | response.status_code = 403 # Forbidden 39 | return response 40 | 41 | @action 42 | def show(self, username): 43 | self.user = get_object_or_404(models.User, username=username) 44 | 45 | @action 46 | def edit(self, username): 47 | self.user = get_object_or_404(models.User, username=username) 48 | self.form = forms.UserChangeForm(instance=self.user) 49 | 50 | @action 51 | def update(self, username): 52 | self.user = get_object_or_404(models.User, username=username) 53 | self.form = forms.UserChangeForm(self.request.POST, instance=self.user) 54 | if self.form.is_valid(): 55 | self.form.save() 56 | return redirect(self.user) 57 | 58 | response = self.edit.render() 59 | response.status_code = 403 60 | return response 61 | 62 | @action 63 | def destroy(self, username): 64 | self.user = get_object_or_404(models.User, username=username) 65 | self.user.delete() 66 | return redirect('/users') 67 | 68 | `Resource` uses [django-clsview][] to define class-based views; the extensive 69 | use of `self` is safe because a new instance is created for each request. 70 | 71 | [django-clsview]: http://github.com/zacharyvoase/django-clsview 72 | 73 | You might notice that there are no explicit calls to `render_to_response()`; 74 | most of the time you’ll want to render the same templates: `"user/index.html"`, 75 | `"user/new.html"`, `"user/show.html"` and `"user/edit.html"`. Therefore, if your 76 | action doesn’t return anything (i.e. returns `None`), a template corresponding 77 | to the resource and action will be rendered. See the 78 | [renderer documentation](/renderer) for more information. 79 | 80 | 81 | ### Decorating Resources 82 | 83 | If you want to apply a view decorator to an entire `Resource`, you can use the 84 | `_decorate()` method (as provided by `django-clsview`): 85 | 86 | :::python 87 | class User(Resource): 88 | ... 89 | User = User._decorate(auth_required) 90 | 91 | Note that this returns a *new resource*, so you need to re-assign the result to 92 | the old name. 93 | 94 | 95 | ### Decorating Actions 96 | 97 | Because actions don’t have the typical function signature of a Django view (i.e. 98 | `view(request, *args, **kwargs)`), most view decorators won’t work on an action 99 | method. For this reason, Dagny provides a simple decorator-wrapper which will 100 | adapt a normal view decorator to work on an action method. Use it like this: 101 | 102 | :::python 103 | class User(Resource): 104 | @action 105 | @action.deco(auth_required) 106 | def edit(self, username): 107 | ... 108 | 109 | `deco()` is a staticmethod on the `Action` class, purely for convenience. 110 | Remember: `@action.deco()` must come *below* `@action`, otherwise you’re likely 111 | to get a cryptic error message at runtime. 112 | -------------------------------------------------------------------------------- /example/users/tests/test_integration.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import datetime 4 | 5 | from django.contrib.auth import models 6 | from django.test import TestCase 7 | from django.utils import formats, simplejson 8 | 9 | 10 | class UserResourceTest(TestCase): 11 | 12 | def create_user(self): 13 | self.user = models.User.objects.create_user("zack", "z@zacharyvoase.com", "hello") 14 | 15 | def user_json(self): 16 | return { 17 | "username": self.user.username, 18 | "first_name": "", 19 | "last_name": "" 20 | } 21 | 22 | def test_index(self): 23 | response = self.client.get("/users/") 24 | self.assertEqual(response.status_code, 200) 25 | self.assert_('' in response.content) 26 | 27 | def test_index_json(self): 28 | self.create_user() 29 | 30 | response1 = self.client.get("/users/?format=json") 31 | self.assertEqual(response1.status_code, 200) 32 | self.assertEqual(simplejson.loads(response1.content), [self.user_json()]) 33 | 34 | response2 = self.client.get("/users/", HTTP_ACCEPT="application/json") 35 | self.assertEqual(response2.status_code, 200) 36 | self.assertEqual(simplejson.loads(response2.content), [self.user_json()]) 37 | 38 | def test_new(self): 39 | response = self.client.get("/users/new/") 40 | self.assertEqual(response.status_code, 200) 41 | self.assert_('
' in response.content) 42 | 43 | def test_create(self): 44 | initial_user_count = models.User.objects.count() 45 | 46 | response = self.client.post("/users/", { 47 | "username": "zack", 48 | "password1": "hello", 49 | "password2": "hello" 50 | }) 51 | self.assertEqual(response.status_code, 302) 52 | self.assertEqual(response['Location'], 'http://testserver/users/1/') 53 | 54 | eventual_user_count = models.User.objects.count() 55 | self.assertEqual(eventual_user_count, initial_user_count + 1) 56 | 57 | def test_create_invalid(self): 58 | initial_user_count = models.User.objects.count() 59 | response = self.client.post("/users/", { 60 | "username": "!!", 61 | "password1": "foo", 62 | "password2": "bar" 63 | }) 64 | self.assertEqual(response.status_code, 403) 65 | eventual_user_count = models.User.objects.count() 66 | self.assertEqual(eventual_user_count, initial_user_count) 67 | 68 | def test_show(self): 69 | self.create_user() 70 | 71 | response = self.client.get("/users/%d/" % self.user.id) 72 | self.assertEqual(response.status_code, 200) 73 | self.assert_("Username: %s" % self.user.username in response.content) 74 | 75 | def test_show_json(self): 76 | self.create_user() 77 | 78 | response1 = self.client.get("/users/%d/?format=json" % self.user.id) 79 | self.assertEqual(response1.status_code, 200) 80 | self.assertEqual(simplejson.loads(response1.content), self.user_json()) 81 | 82 | response2 = self.client.get("/users/%d/" % self.user.id, 83 | HTTP_ACCEPT="application/json") 84 | self.assertEqual(response2.status_code, 200) 85 | self.assertEqual(simplejson.loads(response2.content), self.user_json()) 86 | 87 | def test_edit(self): 88 | self.create_user() 89 | 90 | response = self.client.get("/users/%d/edit/" % self.user.id) 91 | self.assertEqual(response.status_code, 200) 92 | self.assert_('' % self.user.id in response.content) 93 | 94 | def test_update(self): 95 | self.create_user() 96 | 97 | response = self.client.post("/users/%d/" % self.user.id, { 98 | "username": self.user.username, 99 | "first_name": "Zachary", 100 | "last_name": "Voase", 101 | "email": "z@zacharyvoase.com", 102 | "password": self.user.password, 103 | "last_login": formats.localize_input(datetime.datetime.now()), 104 | "date_joined": formats.localize_input(datetime.datetime.now()), 105 | }) 106 | self.assertEqual(response.status_code, 302) 107 | self.assertEqual(response['Location'], 'http://testserver/users/1/') 108 | 109 | self.user = models.User.objects.get(id=self.user.id) 110 | self.assertEqual(self.user.first_name, "Zachary") 111 | self.assertEqual(self.user.last_name, "Voase") 112 | 113 | def test_destroy(self): 114 | self.create_user() 115 | 116 | initial_user_count = models.User.objects.count() 117 | 118 | response = self.client.delete("/users/%d/" % self.user.id) 119 | self.assertEqual(response.status_code, 302) 120 | self.assertEqual(response['Location'], "http://testserver/users/") 121 | 122 | eventual_user_count = models.User.objects.count() 123 | self.assertEqual(eventual_user_count, initial_user_count - 1) 124 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dagny 2 | 3 | Dagny is a [Django][] adaptation of [Ruby on Rails][]’s Resource-Oriented 4 | Architecture (a.k.a. ‘RESTful Rails’). 5 | Dagny makes it *really easy* to build **resourceful** web applications. 6 | 7 | [django]: http://djangoproject.com/ 8 | [ruby on rails]: http://rubyonrails.org/ 9 | 10 | You can read the full documentation [here](http://zacharyvoase.github.com/dagny/). 11 | 12 | At present, this project is in an experimental phase, so APIs are very liable to 13 | change. **You have been warned.** 14 | 15 | P.S.: the name is [a reference][dagny taggart]. 16 | 17 | [dagny taggart]: http://en.wikipedia.org/wiki/List_of_characters_in_Atlas_Shrugged#Dagny_Taggart 18 | 19 | 20 | ## Motivation 21 | 22 | Rails makes building RESTful web applications incredibly easy, because 23 | resource-orientation is baked into the framework—it’s actually harder to make 24 | your app *un*RESTful. 25 | 26 | I wanted to build a similar system for Django; one that made it incredibly 27 | simple to model my resources and serve them up with the minimum possible code. 28 | 29 | One of the most important requirements was powerful yet simple content 30 | negotiation: separating application logic from the rendering of responses makes 31 | writing an API an effortless task. 32 | 33 | Finally, as strong as Rails’s inspiration was, it still needed to be consistent 34 | with the practices and idioms of the Django and Python ecosystems. Dagny doesn’t 35 | use any metaclasses (yet), and the code is well-documented and readable by most 36 | Pythonista’s standards. 37 | 38 | 39 | ## Appetizer 40 | 41 | Define a resource: 42 | 43 | from dagny import Resource, action 44 | from django.shortcuts import get_object_or_404, redirect 45 | from polls import forms, models 46 | 47 | class Poll(Resource): 48 | 49 | @action 50 | def index(self): 51 | self.polls = models.Poll.objects.all() 52 | 53 | @action 54 | def new(self): 55 | self.form = forms.PollForm() 56 | 57 | @action 58 | def create(self): 59 | self.form = forms.PollForm(self.request.POST) 60 | if self.form.is_valid(): 61 | self.poll = self.form.save() 62 | return redirect("Poll#show", self.poll.id) 63 | 64 | return self.new.render() 65 | 66 | @action 67 | def edit(self, poll_id): 68 | self.poll = get_object_or_404(models.Poll, id=int(poll_id)) 69 | self.form = forms.PollForm(instance=self.poll) 70 | 71 | @action 72 | def update(self, poll_id): 73 | self.poll = get_object_or_404(models.Poll, id=int(poll_id)) 74 | self.form = forms.PollForm(self.request.POST, instance=self.poll) 75 | if self.form.is_valid(): 76 | self.form.save() 77 | return redirect("Poll#show", self.poll.id) 78 | 79 | return self.edit.render() 80 | 81 | @action 82 | def destroy(self, poll_id): 83 | self.poll = get_object_or_404(models.Poll, id=int(poll_id)) 84 | self.poll.delete() 85 | return redirect("Poll#index") 86 | 87 | Create the templates: 88 | 89 | 90 |
    91 | {% for poll in self.polls %} 92 |
  1. {{ poll.name }}
  2. 93 | {% endfor %} 94 |
95 |

Create a poll

96 | 97 | 98 | 99 | {% csrf_token %} 100 | {{ self.form.as_p }} 101 | 102 |
103 | 104 | 105 |

Name: {{ self.poll.name }}

106 |

Edit this poll

107 | 108 | 109 |
110 | {% csrf_token %} 111 | {{ self.form.as_p }} 112 | 113 |
114 | 115 | Set up the URLs: 116 | 117 | from django.conf.urls.defaults import * 118 | from dagny.urls import resources 119 | 120 | urlpatterns = patterns('', 121 | (r'^polls/', resources('polls.resources.Poll', name='Poll')), 122 | ) 123 | 124 | Done. 125 | 126 | 127 | ## Example Project 128 | 129 | There’s a more comprehensive [example project][] which showcases a user 130 | management app, built in very few lines of code on top of the standard 131 | `django.contrib.auth` app. 132 | 133 | [example project]: http://github.com/zacharyvoase/dagny/tree/master/example/ 134 | 135 | To get it running: 136 | 137 | git clone 'git://github.com/zacharyvoase/dagny.git' 138 | cd dagny/ 139 | pip install -r REQUIREMENTS # Installs runtime requirements 140 | pip install -r REQUIREMENTS.test # Installs testing requirements 141 | cd example/ 142 | ./manage.py syncdb # Creates db/development.sqlite3 143 | ./manage.py test users # Runs all the tests 144 | ./manage.py runserver 145 | 146 | Then just visit to see it in action! 147 | 148 | 149 | ## License 150 | 151 | This is free and unencumbered software released into the public domain. 152 | 153 | Anyone is free to copy, modify, publish, use, compile, sell, or distribute this 154 | software, either in source code form or as a compiled binary, for any purpose, 155 | commercial or non-commercial, and by any means. 156 | 157 | In jurisdictions that recognize copyright laws, the author or authors of this 158 | software dedicate any and all copyright interest in the software to the public 159 | domain. We make this dedication for the benefit of the public at large and to 160 | the detriment of our heirs and successors. We intend this dedication to be an 161 | overt act of relinquishment in perpetuity of all present and future rights to 162 | this software under copyright law. 163 | 164 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 165 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 166 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE 167 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF 168 | CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 169 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 170 | 171 | For more information, please refer to 172 | -------------------------------------------------------------------------------- /doc/index.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Dagny 4 | 5 | Dagny is a [Django][] adaptation of [Ruby on Rails][]’s Resource-Oriented 6 | Architecture (a.k.a. ‘RESTful Rails’). 7 | Dagny makes it *really easy* to build **resourceful** web applications. 8 | 9 | [django]: http://djangoproject.com/ 10 | [ruby on rails]: http://rubyonrails.org/ 11 | 12 | At present, this project is in an experimental phase, so APIs are very liable to 13 | change. **You have been warned.** 14 | 15 | P.S.: the name is [a reference][dagny taggart]. 16 | 17 | [dagny taggart]: http://en.wikipedia.org/wiki/List_of_characters_in_Atlas_Shrugged#Dagny_Taggart 18 | 19 | 20 | ## [Tutorial](/tutorial) 21 | 22 | 23 | ## [Reference](/reference) 24 | 25 | 26 | ## Motivation 27 | 28 | Rails makes building RESTful web applications incredibly easy, because 29 | resource-orientation is baked into the framework—it’s actually harder to make 30 | your app *un*RESTful. 31 | 32 | I wanted to build a similar system for Django; one that made it incredibly 33 | simple to model my resources and serve them up with the minimum possible code. 34 | 35 | One of the most important requirements was powerful yet simple content 36 | negotiation: separating application logic from the rendering of responses makes 37 | writing an API an effortless task. 38 | 39 | Finally, as strong as Rails’s inspiration was, it still needed to be consistent 40 | with the practices and idioms of the Django and Python ecosystems. Dagny 41 | doesn’t use any metaclasses (yet), and the code is well-documented and readable 42 | by most Pythonista’s standards. 43 | 44 | 45 | ## Appetizer 46 | 47 | Define a resource: 48 | 49 | #!python 50 | from dagny import Resource, action 51 | from django.shortcuts import get_object_or_404, redirect 52 | from polls import forms, models 53 | 54 | class Poll(Resource): 55 | 56 | @action 57 | def index(self): 58 | self.polls = models.Poll.objects.all() 59 | 60 | @action 61 | def new(self): 62 | self.form = forms.PollForm() 63 | 64 | @action 65 | def create(self): 66 | self.form = forms.PollForm(self.request.POST) 67 | if self.form.is_valid(): 68 | self.poll = self.form.save() 69 | return redirect("Poll#show", self.poll.id) 70 | 71 | return self.new.render() 72 | 73 | @action 74 | def edit(self, poll_id): 75 | self.poll = get_object_or_404(models.Poll, id=int(poll_id)) 76 | self.form = forms.PollForm(instance=self.poll) 77 | 78 | @action 79 | def update(self, poll_id): 80 | self.poll = get_object_or_404(models.Poll, id=int(poll_id)) 81 | self.form = forms.PollForm(self.request.POST, instance=self.poll) 82 | if self.form.is_valid(): 83 | self.form.save() 84 | return redirect("Poll#show", self.poll.id) 85 | 86 | return self.edit.render() 87 | 88 | @action 89 | def destroy(self, poll_id): 90 | self.poll = get_object_or_404(models.Poll, id=int(poll_id)) 91 | self.poll.delete() 92 | return redirect("Poll#index") 93 | 94 | Create the templates: 95 | 96 | :::html+django 97 | 98 |
    99 | {% for poll in self.polls %} 100 |
  1. {{ poll.name }}
  2. 101 | {% endfor %} 102 |
103 |

Create a poll

104 | 105 | 106 |
107 | {% csrf_token %} 108 | {{ self.form.as_p }} 109 | 110 |
111 | 112 | 113 |

Name: {{ self.poll.name }}

114 |

Edit this poll

115 | 116 | 117 |
118 | {% csrf_token %} 119 | {{ self.form.as_p }} 120 | 121 |
122 | 123 | Set up the URLs: 124 | 125 | #!python 126 | from django.conf.urls.defaults import * 127 | from dagny.urls import resources 128 | 129 | urlpatterns = patterns('', 130 | (r'^polls/', resources('polls.resources.Poll', name='Poll')), 131 | ) 132 | 133 | Done. 134 | 135 | 136 | ## Example Project 137 | 138 | There’s a more comprehensive [example project][] which showcases a user 139 | management app, built in very few lines of code on top of the standard 140 | `django.contrib.auth` app. 141 | 142 | [example project]: http://github.com/zacharyvoase/dagny/tree/master/example/ 143 | 144 | To get it running: 145 | 146 | :::bash 147 | git clone 'git://github.com/zacharyvoase/dagny.git' 148 | cd dagny/ 149 | pip install -r REQUIREMENTS # Installs runtime requirements 150 | pip install -r REQUIREMENTS.test # Installs testing requirements 151 | cd example/ 152 | ./manage.py syncdb # Creates db/development.sqlite3 153 | ./manage.py test users # Runs all the tests 154 | ./manage.py runserver 155 | 156 | Then just visit to see it in action! 157 | 158 | 159 | ## License 160 | 161 | This is free and unencumbered software released into the public domain. 162 | 163 | Anyone is free to copy, modify, publish, use, compile, sell, or distribute this 164 | software, either in source code form or as a compiled binary, for any purpose, 165 | commercial or non-commercial, and by any means. 166 | 167 | In jurisdictions that recognize copyright laws, the author or authors of this 168 | software dedicate any and all copyright interest in the software to the public 169 | domain. We make this dedication for the benefit of the public at large and to 170 | the detriment of our heirs and successors. We intend this dedication to be an 171 | overt act of relinquishment in perpetuity of all present and future rights to 172 | this software under copyright law. 173 | 174 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 175 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 176 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE 177 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF 178 | CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 179 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 180 | 181 | For more information, please refer to 182 | -------------------------------------------------------------------------------- /src/dagny/urls/styles.py: -------------------------------------------------------------------------------- 1 | class URLStyle(object): 2 | 3 | """ 4 | Generic class for defining resource URL styles. 5 | 6 | `URLStyle` can be used to create callables which will work for the 7 | interface defined in `dagny.urls.router.URLRouter`. Subclass and override 8 | the `collection()`, `new()`, `member()`, `edit()`, `singleton()` and 9 | `singleton_edit()` methods to customize your URLs. You can use one of the 10 | several defined styles in this module as a template. 11 | """ 12 | 13 | METHODS = { 14 | 'collection': { 15 | 'GET': 'index', 16 | 'POST': 'create' 17 | }, 18 | 'member': { 19 | 'GET': 'show', 20 | 'POST': 'update', 21 | 'PUT': 'update', 22 | 'DELETE': 'destroy' 23 | }, 24 | 'new': {'GET': 'new'}, 25 | 'edit': {'GET': 'edit'}, 26 | 'singleton': { 27 | 'GET': 'show', 28 | 'POST': 'update', 29 | 'PUT': 'update', 30 | 'DELETE': 'destroy' 31 | }, 32 | 'singleton_edit': {'GET': 'edit'}, 33 | } 34 | 35 | def __call__(self, url, id_param): 36 | id_regex = self._get_id_regex(id_param) 37 | 38 | if url in ('member', 'edit'): 39 | return getattr(self, url)(id_regex), self.METHODS[url] 40 | return getattr(self, url)(), self.METHODS[url] 41 | 42 | def _get_id_regex(self, id_param): 43 | 44 | """ 45 | Resolve `(name, regex)` => `'?Pregex'`. 46 | 47 | Since the style methods should add parentheses around the ID regex 48 | fragment, the output for named ID parameters is not surrounded by 49 | parentheses itself. 50 | """ 51 | 52 | if isinstance(id_param, basestring): 53 | return id_param 54 | elif isinstance(id_param, tuple): 55 | if len(id_param) != 2: 56 | raise ValueError("id param must be a (name, regex) pair") 57 | name, regex = id_param 58 | return '?P<%s>%s' % (name, regex) 59 | raise TypeError('id param must be a string or (name, regex) pair, ' 60 | 'not %r' % (type(id_param),)) 61 | 62 | # Publicly-overrideable methods for customizing style behaviour. 63 | 64 | def collection(self): 65 | raise NotImplementedError 66 | 67 | def new(self): 68 | raise NotImplementedError 69 | 70 | def member(self, id_regex): 71 | raise NotImplementedError 72 | 73 | def edit(self, id_regex): 74 | raise NotImplementedError 75 | 76 | def singleton(self): 77 | raise NotImplementedError 78 | 79 | def singleton_edit(self): 80 | raise NotImplementedError 81 | 82 | 83 | class DjangoURLStyle(URLStyle): 84 | 85 | """ 86 | Standard Django-style URLs. 87 | 88 | URL | action | args | kwargs 89 | ---------------+--------+--------+-------- 90 | /posts/ | index | () | {} 91 | /posts/new/ | new | () | {} 92 | /posts/1/ | show | ('1',) | {} 93 | /posts/1/edit/ | edit | ('1',) | {} 94 | """ 95 | 96 | def collection(self): 97 | return r'^$' 98 | 99 | def new(self): 100 | return r'^new/$' 101 | 102 | def member(self, id_regex): 103 | return r'^(%s)/$' % (id_regex,) 104 | 105 | def edit(self, id_regex): 106 | return r'^(%s)/edit/$' % (id_regex,) 107 | 108 | def singleton(self): 109 | return r'^$' 110 | 111 | def singleton_edit(self): 112 | return r'^edit/$' 113 | 114 | 115 | class AtomPubURLStyle(URLStyle): 116 | 117 | """ 118 | Atom Publishing Protocol-style URLs. 119 | 120 | The main difference between this and Django-style URLs is the lack of 121 | trailing slashes on leaf nodes. 122 | 123 | URL | action | args | kwargs 124 | --------------+--------+--------+-------- 125 | /posts/ | index | () | {} 126 | /posts/new | new | () | {} 127 | /posts/1 | show | ('1',) | {} 128 | /posts/1/edit | edit | ('1',) | {} 129 | """ 130 | 131 | def collection(self): 132 | return r'^$' 133 | 134 | def new(self): 135 | return r'^new$' 136 | 137 | def member(self, id_regex): 138 | return r'^(%s)$' % (id_regex,) 139 | 140 | def edit(self, id_regex): 141 | return r'^(%s)/edit$' % (id_regex,) 142 | 143 | def singleton(self): 144 | return r'^$' 145 | 146 | def singleton_edit(self): 147 | return r'^edit$' 148 | 149 | 150 | class RailsURLStyle(URLStyle): 151 | 152 | r""" 153 | Ruby on Rails-style URLs. 154 | 155 | This URL style is quite advanced; it will also capture format extensions 156 | and pass them through as a kwarg. As with `AtomPubURLStyle`, trailing 157 | slashes are not mandatory on leaf nodes. 158 | 159 | URL | action | args | kwargs 160 | --------------+--------+------+------------------------------ 161 | /posts | index | () | {} 162 | /posts.json | index | () | {'format': 'json'} 163 | /posts/ | index | () | {} 164 | /posts/new | new | () | {} 165 | /posts/1 | show | () | {'id': '1'} 166 | /posts/1.json | show | () | {'id': '1', 'format': 'json'} 167 | /posts/1/ | show | () | {'id': '1'} 168 | /posts/1/edit | edit | () | {'id': '1'} 169 | 170 | **Note**: due to limitations of the URLconf system, your IDs/slugs have to 171 | come in as named parameters. By default, the parameter will be called `id`, 172 | but you can select a different one using the `id` keyword argument to the 173 | URL helpers: 174 | 175 | urlpatterns = patterns('', 176 | (r'posts', resources('myapp.resources.Post', name='Post', 177 | id=('slug', r'[\w\-]+'))), 178 | ) 179 | 180 | Another caveat: do not terminate your inclusion regex with a slash, or the 181 | format extension on the resource index won't work. 182 | 183 | You can customize the format extension regex (and hence the kwarg name) by 184 | subclassing and overriding the `FORMAT_EXTENSION_RE` attribute, e.g.: 185 | 186 | class MyRailsURLStyle(RailsURLStyle): 187 | FORMAT_EXTENSION_RE = r'(?P[A-Za-z0-9]+)' 188 | """ 189 | 190 | FORMAT_EXTENSION_RE = r'(?P\.\w[\w\-\.]*)' 191 | 192 | def _get_id_regex(self, id_param): 193 | """Co-erce *all* IDs to named parameters, defaulting to `'id'`.""" 194 | 195 | if isinstance(id_param, basestring) and not id_param.startswith('?P<'): 196 | return super(RailsURLStyle, self)._get_id_regex(('id', id_param)) 197 | return super(RailsURLStyle, self)._get_id_regex(id_param) 198 | 199 | def collection(self): 200 | return r'^%s?/?$' % (self.FORMAT_EXTENSION_RE,) 201 | 202 | def new(self): 203 | return r'^/new/?$' 204 | 205 | def member(self, id_regex): 206 | return r'^/(%s)%s?/?$' % (id_regex, self.FORMAT_EXTENSION_RE) 207 | 208 | def edit(self, id_regex): 209 | return r'^/(%s)/edit/?$' % (id_regex,) 210 | 211 | def singleton(self): 212 | return r'^%s?/?$' % (self.FORMAT_EXTENSION_RE,) 213 | 214 | def singleton_edit(self): 215 | return r'^/edit/?$' 216 | -------------------------------------------------------------------------------- /doc/renderer.md: -------------------------------------------------------------------------------- 1 | # The Renderer 2 | 3 | An action comes in two parts: one part does the processing, and the other (known 4 | as the **renderer**) returns a response to the client. This allows for 5 | transparent content negotiation, and means you never have to write a separate 6 | ‘API’ for your site, or call `render_to_response()` at the bottom of every view 7 | function. 8 | 9 | 10 | ## Renderer Backends 11 | 12 | When an action is triggered by a request, the main body of the action is first 13 | run. If this does not return a `HttpResponse` outright, the renderer kicks in 14 | and performs content negotiation, to decide which **renderer backend** to use. 15 | Each backend is associated with a mimetype, so the renderer will examine the 16 | client headers and resolve a series of acceptable backends, which it will call 17 | in decreasing order of preference until one produces a response. 18 | 19 | There are two types of renderer backend. The most common is the 20 | **specific renderer backend**, which is attached to a single action for a 21 | particular mimetype. Here’s a simple example of a backend for rendering a JSON 22 | representation of a user: 23 | 24 | #!python 25 | from dagny import Resource, action 26 | from django.http import HttpResponse 27 | from django.shortcuts import get_object_or_404 28 | import simplejson 29 | 30 | class User(Resource): 31 | 32 | # ... snip! ... 33 | 34 | @action 35 | def show(self, username): 36 | self.user = get_object_or_404(User, username=username) 37 | 38 | @show.render.json 39 | def show(self): 40 | return HttpResponse(content=simplejson.dumps(self.user.to_dict()), 41 | mimetype='application/json') 42 | 43 | The decorator API is inspired by Python’s built-in `property`. As you can see, 44 | specific renderer backends are methods which accept only `self` (which will be 45 | the resource instance). They’re typically highly coupled with the resource and 46 | action they’re defined on; this one assumes the presence of `self.user`, for 47 | example. 48 | 49 | ### Content Negotiation 50 | 51 | Assume that the `User` resource is mounted at `/users/`. Now, if you fetch 52 | `/users/zacharyvoase/`, you’ll see the `"users/show.html"` template rendered as 53 | a HTML page. If you fetch `/users/zacharyvoase/?format=json`, however, you’ll 54 | get a JSON representation of that user. 55 | 56 | Dagny’s ConNeg mechanism is quite sophisticated; `webob.acceptparse` is used to 57 | parse HTTP `Accept` headers, and these are considered alongside explicit 58 | `format` parameters. So, you could also have passed an 59 | `Accept: application/json` HTTP header in that last example, and it would have 60 | worked. If you’re using `curl`, you could try the following command: 61 | 62 | :::bash 63 | curl -H"Accept: application/json" 'http://mysite.com/users/zacharyvoase/' 64 | 65 | 66 | ## Skipping Renderers 67 | 68 | Sometimes, you will define multiple renderer backends for an action, but in a 69 | few cases a single backend won’t be able to generate a response for that 70 | particular request. You can indicate this by raising `dagny.renderer.Skip`: 71 | 72 | #!python 73 | from dagny.renderer import Skip 74 | from django.http import HttpResponse 75 | 76 | class User(Resource): 77 | # ... snip! ... 78 | 79 | @show.render.rdf_xml 80 | def show(self): 81 | if not hasattr(self, 'graph'): 82 | raise Skip 83 | return HttpResponse(content=self.graph.serialize(), 84 | content_type='application/rdf+xml') 85 | 86 | The renderer will literally skip over this backend and on to the next-best 87 | preferred one. This feature *really* comes in handy when writing 88 | [generic backends](#generic_backends), which will only be able to determine at 89 | runtime whether they are suitable for a given action and request. 90 | 91 | 92 | ## Additional MIME types 93 | 94 | Additional renderers for a single action are defined using the decoration syntax 95 | (`@.render.`) as seen above, but since content negotiation 96 | is based on mimetypes, Dagny keeps a global `dict` (`dagny.conneg.MIMETYPES`) 97 | mapping these **shortcodes** to full mimetype strings. You can create your own 98 | shortcodes, and use them in resource definitions: 99 | 100 | #!python 101 | from dagny.conneg import MIMETYPES 102 | 103 | MIMETYPES['rss'] = 'application/rss+xml' 104 | MIMETYPES['png'] = 'image/png' 105 | MIMETYPES.setdefault('json', 'text/javascript') 106 | 107 | There is already a relatively extensive list of types defined; see the 108 | [`dagny.conneg` module][dagny.conneg] for more information. 109 | 110 | [dagny.conneg]: http://github.com/zacharyvoase/dagny/blob/master/src/dagny/conneg.py 111 | 112 | 113 | ## Generic Backends 114 | 115 | Dagny also supports **generic renderer backends**; these are backends attached 116 | to a `Renderer` instance which will be available on *all* actions by default. 117 | They are simple functions which take both the action instance and the resource 118 | instance. For example, the HTML renderer (which every action has as standard) 119 | looks like: 120 | 121 | #!python 122 | from dagny.action import Action 123 | from dagny.utils import camel_to_underscore, resource_name 124 | 125 | from django.shortcuts import render_to_response 126 | from django.template import RequestContext 127 | 128 | @Action.RENDERER.html 129 | def render_html(action, resource): 130 | template_path_prefix = getattr(resource, 'template_path_prefix', "") 131 | resource_label = camel_to_underscore(resource_name(resource)) 132 | template_name = "%s%s/%s.html" % (template_path_prefix, resource_label, action.name) 133 | 134 | return render_to_response(template_name, { 135 | 'self': resource 136 | }, context_instance=RequestContext(resource.request)) 137 | 138 | To go deeper, `Action.RENDERER` is a globally-shared instance of 139 | `dagny.renderer.Renderer`, whereas the `render` attribute on actions is actually 140 | a `BoundRenderer`. This split is what allows you to define specific backends 141 | that just take `self` (the resource instance), and generic backends which also 142 | take the action. 143 | 144 | Each `BoundRenderer` has a copy of the whole set of generic backends, so you can 145 | operate on them as if they had been defined on that action: 146 | 147 | :::python 148 | class User(Resource): 149 | @action 150 | def show(self, username): 151 | self.user = get_object_or_404(User, username=username) 152 | 153 | # Remove the generic HTML backend from the `show` action alone. 154 | del show.render['html'] 155 | 156 | # Item assignment, even on a `BoundRenderer`, takes generic backend 157 | # functions (i.e. functions which accept both the action *and* the 158 | # resource). 159 | show.render['html'] = my_generic_html_backend 160 | 161 | 162 | ### Skipping in Generic Backends 163 | 164 | As mentioned previously, `dagny.renderer.Skip` becomes very useful when writing 165 | generic backends. For example, here’s a backend which produces RDF/XML 166 | responses, but *only* if `self.graph` exists and is an instance of 167 | `rdflib.Graph`: 168 | 169 | #!python 170 | from dagny.action import Action 171 | from dagny.conneg import MIMETYPES 172 | from dagny.renderer import Skip 173 | from django.http import HttpResponse 174 | import rdflib 175 | 176 | # This is already defined in Dagny by default. 177 | MIMETYPES['rdf_xml'] = 'application/rdf+xml' 178 | 179 | @Action.RENDERER.rdf_xml 180 | def render_rdf_xml(action, resource): 181 | graph = getattr(resource, 'graph', None) 182 | if not isinstance(graph, rdflib.Graph): 183 | raise Skip 184 | 185 | return HttpResponse(content=graph.serialize(format='xml'), 186 | content_type='application/rdf+xml') 187 | -------------------------------------------------------------------------------- /src/dagny/action.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from functools import wraps 4 | 5 | from dagny.renderer import Renderer 6 | from dagny.resource import Resource 7 | from dagny.utils import resource_name 8 | 9 | 10 | class Action(object): 11 | 12 | """ 13 | A descriptor for wrapping an action method. 14 | 15 | >>> action = Action 16 | >>> class X(Resource): 17 | ... @action 18 | ... def show(self): 19 | ... self.attr1 = 'a' 20 | 21 | Appears as an `Action` on the class (and other objects): 22 | 23 | >>> X.show # doctest: +ELLIPSIS 24 | 25 | >>> X.show.render # doctest: +ELLIPSIS 26 | > 27 | 28 | Appears as a `BoundAction` on an instance: 29 | 30 | >>> x = X._new(object()) 31 | >>> x.show # doctest: +ELLIPSIS 32 | 33 | >>> x.show.render # doctest: +ELLIPSIS 34 | > 35 | 36 | ## Actions and Rendering 37 | 38 | The API for `Action` instances has been fine-tuned to allow an easy 39 | interface with the renderer system. When accessed from inside the class 40 | definition, `show.render` is a `BoundRenderer` instance, so you're just 41 | using the standard decorator syntax for defining new renderer backends: 42 | 43 | class User(Resource): 44 | 45 | @action 46 | def show(self, username): 47 | self.user = get_object_or_404(User, username=username) 48 | 49 | @show.render.json 50 | def show(self): 51 | return JSONResponse(self.user.as_dict()) 52 | 53 | # You can also un-define renderer backends for a single action: 54 | del show.render['html'] 55 | 56 | # Or assign generic backends for a particular action: 57 | show.render['html'] = my_generic_html_backend 58 | 59 | When accessed via a resource *instance*, `show.render` will be the 60 | `render()` method of a `BoundAction`, and calling it will invoke the full 61 | rendering process for that particular action, on the current resource. This 62 | is very useful when handling forms; for example: 63 | 64 | class User(Resource): 65 | 66 | @action 67 | def edit(self, username): 68 | self.user = get_object_or_404(User, username=username) 69 | self.form = UserForm(instance=self.user) 70 | # Here the default HTML renderer will kick in; you should 71 | # display the edit user form in a "user/edit.html" template. 72 | 73 | @action 74 | def update(self, username): 75 | self.user = get_object_or_404(User, username=username) 76 | self.form = UserForm(instance=self.user) 77 | if self.form.is_valid(): 78 | self.form.save() 79 | # Returns a response, action ends here. 80 | return redirect(self.user) 81 | 82 | # Applies the `edit` renderer to *this* request, thus rendering 83 | # the "user/edit.html" template but with this resource (and 84 | # hence this `UserForm` instance, which contains errors). 85 | response = self.edit.render() 86 | response.status_code = 403 # Forbidden 87 | return response 88 | 89 | It makes sense to write the `user/edit.html` template so that it renders 90 | forms dynamically; this means the filled-in fields and error messages will 91 | propagate automatically, without any extra work on your part. 92 | """ 93 | 94 | # Global renderer to allow definition of generic renderer backends. 95 | RENDERER = Renderer() 96 | 97 | @staticmethod 98 | def deco(decorator): 99 | 100 | """ 101 | Static method to wrap a typical view decorator as an action decorator. 102 | 103 | Usage is relatively simple, but remember that `@action.deco()` must come 104 | *below* `@action`: 105 | 106 | class User(Resource): 107 | 108 | @action 109 | @action.deco(auth_required) 110 | def edit(self, username): 111 | ... 112 | 113 | This will wrap the decorator so that *it* sees a function with a 114 | signature of `view(request, *args, **kwargs)`; this function is an 115 | adapter which will then call the action appropriately. 116 | 117 | >>> def decorator(view): 118 | ... def wrapper(request, *args, **kwargs): 119 | ... print "WRAPPER (In):" 120 | ... print " ", repr(request), args, kwargs 121 | ... 122 | ... request = "ModifiedRequest" 123 | ... args = ("another_user",) 124 | ... kwargs.update(authenticated=True) 125 | ... 126 | ... print "WRAPPER (Out):" 127 | ... print " ", repr(request), args, kwargs 128 | ... 129 | ... return view(request, *args, **kwargs) 130 | ... return wrapper 131 | 132 | >>> def view(self, username): 133 | ... print "VIEW:" 134 | ... print " ", repr(self.request), self.args, self.params 135 | 136 | >>> resource = type('Resource', (object,), {})() 137 | >>> resource.request = "Request" 138 | >>> resource.args = ("some_user",) 139 | >>> resource.params = {} 140 | 141 | >>> view(resource, "some_user") 142 | VIEW: 143 | 'Request' ('some_user',) {} 144 | 145 | >>> Action.deco(decorator)(view)(resource, "some_user") 146 | WRAPPER (In): 147 | 'Request' ('some_user',) {} 148 | WRAPPER (Out): 149 | 'ModifiedRequest' ('another_user',) {'authenticated': True} 150 | VIEW: 151 | 'ModifiedRequest' ('another_user',) {'authenticated': True} 152 | 153 | """ 154 | 155 | @wraps(decorator) 156 | def deco_wrapper(action_func): 157 | @wraps(action_func) 158 | def action_wrapper(self, *args): 159 | @wraps(action_func) 160 | def adapter(request, *adapter_args, **params): 161 | self.request = request 162 | self.args = adapter_args 163 | self.params = params 164 | return action_func(self, *self.args) 165 | return decorator(adapter)(self.request, *args, **self.params) 166 | return action_wrapper 167 | return deco_wrapper 168 | 169 | def __init__(self, method): 170 | self.method = method 171 | self.name = method.__name__ 172 | self.render = self.RENDERER._bind(self) 173 | 174 | def __repr__(self): 175 | return "" % (self.name, id(self)) 176 | 177 | def __get__(self, resource, resource_cls): 178 | if isinstance(resource, Resource): 179 | return BoundAction(self, resource, resource_cls) 180 | return self 181 | 182 | 183 | class BoundAction(object): 184 | 185 | """An action which has been bound to a specific resource instance.""" 186 | 187 | def __init__(self, action, resource, resource_cls): 188 | self.action = action 189 | self.resource = resource 190 | self.resource_cls = resource_cls 191 | self.resource_name = resource_name(resource_cls) 192 | 193 | def __repr__(self): 194 | return "" % (self.resource_name, self.action.name, id(self)) 195 | 196 | def __call__(self): 197 | response = self.action.method(self.resource, *self.resource.args) 198 | if response: 199 | return response 200 | return self.render() 201 | 202 | def render(self, *args, **kwargs): 203 | return self.action.render(self.resource, *args, **kwargs) 204 | 205 | @property 206 | def name(self): 207 | return self.action.name 208 | -------------------------------------------------------------------------------- /example/users/tests/test_routing.py: -------------------------------------------------------------------------------- 1 | from django.core.urlresolvers import NoReverseMatch, Resolver404, reverse, resolve 2 | from django.test import TestCase 3 | 4 | from users import resources 5 | 6 | 7 | COLLECTION_METHODS = { 8 | 'GET': 'index', 9 | 'POST': 'create' 10 | } 11 | MEMBER_METHODS = { 12 | 'GET': 'show', 13 | 'POST': 'update', 14 | 'PUT': 'update', 15 | 'DELETE': 'destroy' 16 | } 17 | SINGLETON_METHODS = { 18 | 'GET': 'show', 19 | 'POST': 'update', 20 | 'PUT': 'update', 21 | 'DELETE': 'destroy' 22 | } 23 | EDIT_METHODS = {'GET': 'edit'} 24 | NEW_METHODS = {'GET': 'new'} 25 | 26 | 27 | class RoutingTest(TestCase): 28 | 29 | def assert_resolves(self, url, func, *args, **kwargs): 30 | resolved = resolve(url) 31 | self.assertEqual(resolved.func, func) 32 | self.assertEqual(resolved.args, args) 33 | # Check that `resolved.kwargs` is a superset of `kwargs`. 34 | for kw, value in kwargs.items(): 35 | self.assertIn(kw, resolved.kwargs) 36 | self.assertEqual(resolved.kwargs[kw], value) 37 | # Allows for further user-level assertions. 38 | return resolved 39 | 40 | 41 | class DefaultRoutingTest(RoutingTest): 42 | 43 | def test_index(self): 44 | self.assertEqual(reverse('User#index'), '/users/') 45 | self.assert_resolves('/users/', resources.User, 46 | methods=COLLECTION_METHODS) 47 | 48 | def test_show(self): 49 | self.assertEqual(reverse('User#show', args=(1,)), '/users/1/') 50 | self.assert_resolves('/users/1/', resources.User, 51 | '1', methods=MEMBER_METHODS) 52 | 53 | # Fails for invalid IDs. 54 | self.assertRaises(NoReverseMatch, reverse, 'User#show', 55 | args=('invalid',)) 56 | 57 | def test_new(self): 58 | self.assertEqual(reverse('User#new'), '/users/new/') 59 | self.assert_resolves('/users/new/', resources.User, 60 | methods=NEW_METHODS) 61 | 62 | def test_edit(self): 63 | self.assertEqual(reverse('User#edit', args=(1,)), '/users/1/edit/') 64 | self.assert_resolves('/users/1/edit/', resources.User, 65 | '1', methods=EDIT_METHODS) 66 | 67 | def test_singleton(self): 68 | self.assertEqual(reverse('Account#show'), '/account/') 69 | self.assertEqual(reverse('Account#update'), '/account/') 70 | self.assertEqual(reverse('Account#destroy'), '/account/') 71 | self.assert_resolves('/account/', resources.Account, 72 | methods=SINGLETON_METHODS) 73 | 74 | def test_singleton_new(self): 75 | self.assertEqual(reverse('Account#new'), '/account/new/') 76 | self.assert_resolves('/account/new/', resources.Account, 77 | methods=NEW_METHODS) 78 | 79 | def test_singleton_edit(self): 80 | self.assertEqual(reverse('Account#edit'), '/account/edit/') 81 | self.assert_resolves('/account/edit/', resources.Account, 82 | methods=EDIT_METHODS) 83 | 84 | 85 | class AtomPubRoutingTest(DefaultRoutingTest): 86 | 87 | def test_index(self): 88 | self.assertEqual(reverse('UserAtomPub#index'), '/users-atompub/') 89 | self.assert_resolves('/users-atompub/', resources.User, 90 | methods=COLLECTION_METHODS) 91 | 92 | def test_show(self): 93 | self.assertEqual(reverse('UserAtomPub#show', args=(1,)), 94 | '/users-atompub/1') 95 | self.assert_resolves('/users-atompub/1', resources.User, 96 | '1', methods=MEMBER_METHODS) 97 | 98 | # Fails for invalid IDs. 99 | self.assertRaises(NoReverseMatch, reverse, 'UserAtomPub#show', 100 | args=('invalid',)) 101 | 102 | def test_new(self): 103 | self.assertEqual(reverse('UserAtomPub#new'), '/users-atompub/new') 104 | self.assert_resolves('/users-atompub/new', resources.User, 105 | methods=NEW_METHODS) 106 | 107 | def test_edit(self): 108 | self.assertEqual(reverse('UserAtomPub#edit', args=(1,)), 109 | '/users-atompub/1/edit') 110 | self.assert_resolves('/users-atompub/1/edit', resources.User, 111 | '1', methods=EDIT_METHODS) 112 | 113 | def test_singleton(self): 114 | self.assertEqual(reverse('AccountAtomPub#show'), '/account-atompub/') 115 | self.assertEqual(reverse('AccountAtomPub#update'), '/account-atompub/') 116 | self.assertEqual(reverse('AccountAtomPub#destroy'), '/account-atompub/') 117 | self.assert_resolves('/account-atompub/', resources.Account, 118 | methods=SINGLETON_METHODS) 119 | 120 | def test_singleton_new(self): 121 | self.assertEqual(reverse('AccountAtomPub#new'), '/account-atompub/new') 122 | self.assert_resolves('/account-atompub/new', resources.Account, 123 | methods=NEW_METHODS) 124 | 125 | def test_singleton_edit(self): 126 | self.assertEqual(reverse('AccountAtomPub#edit'), '/account-atompub/edit') 127 | self.assert_resolves('/account-atompub/edit', resources.Account, 128 | methods=EDIT_METHODS) 129 | 130 | 131 | class RailsRoutingTest(DefaultRoutingTest): 132 | 133 | def test_index(self): 134 | self.assertEqual(reverse('UserRails#index'), '/users-rails') 135 | self.assert_resolves('/users-rails', resources.User, 136 | methods=COLLECTION_METHODS) 137 | self.assert_resolves('/users-rails/', resources.User, 138 | methods=COLLECTION_METHODS) 139 | 140 | def test_index_with_format(self): 141 | self.assertEqual(reverse('UserRails#index', kwargs={'format': '.json'}), 142 | '/users-rails.json') 143 | self.assert_resolves('/users-rails.json', resources.User, 144 | methods=COLLECTION_METHODS, format='.json') 145 | 146 | def test_show(self): 147 | self.assertEqual(reverse('UserRails#show', kwargs={'id': 1}), 148 | '/users-rails/1') 149 | self.assert_resolves('/users-rails/1', 150 | resources.User, 151 | id='1', methods=MEMBER_METHODS) 152 | self.assert_resolves('/users-rails/1/', 153 | resources.User, 154 | id='1', methods=MEMBER_METHODS) 155 | 156 | # Fails for invalid IDs. 157 | self.assertRaises(NoReverseMatch, reverse, 'UserRails#show', 158 | kwargs={'id': 'invalid'}) 159 | self.assertRaises(Resolver404, resolve, '/users-rails/invalid') 160 | self.assertRaises(Resolver404, resolve, '/users-rails/invalid.json') 161 | self.assertRaises(Resolver404, resolve, '/users-rails/invalid/') 162 | 163 | def test_show_with_format(self): 164 | self.assertEqual(reverse('UserRails#show', 165 | kwargs={'id': 1, 'format': '.json'}), 166 | '/users-rails/1.json') 167 | self.assert_resolves('/users-rails/1.json', 168 | resources.User, 169 | id='1', methods=MEMBER_METHODS, format='.json') 170 | 171 | def test_new(self): 172 | self.assertEqual(reverse('UserRails#new'), '/users-rails/new') 173 | self.assert_resolves('/users-rails/new', resources.User, 174 | methods=NEW_METHODS) 175 | self.assert_resolves('/users-rails/new/', resources.User, 176 | methods=NEW_METHODS) 177 | self.assertRaises(Resolver404, resolve, '/users-rails/new/foobar') 178 | self.assertRaises(Resolver404, resolve, '/users-rails/new.foobar') 179 | 180 | def test_edit(self): 181 | self.assertEqual(reverse('UserRails#edit', kwargs={'id': 1}), 182 | '/users-rails/1/edit') 183 | self.assert_resolves('/users-rails/1/edit', resources.User, 184 | id='1', methods=EDIT_METHODS) 185 | self.assert_resolves('/users-rails/1/edit/', resources.User, 186 | id='1', methods=EDIT_METHODS) 187 | self.assertRaises(Resolver404, resolve, '/users-rails/1/edit/foobar') 188 | self.assertRaises(Resolver404, resolve, '/users-rails/1/edit.foobar') 189 | 190 | def test_singleton(self): 191 | self.assertEqual(reverse('AccountRails#show'), '/account-rails') 192 | self.assertEqual(reverse('AccountRails#update'), '/account-rails') 193 | self.assertEqual(reverse('AccountRails#destroy'), '/account-rails') 194 | self.assert_resolves('/account-rails', resources.Account, 195 | methods=SINGLETON_METHODS) 196 | self.assert_resolves('/account-rails/', resources.Account, 197 | methods=SINGLETON_METHODS) 198 | 199 | def test_singleton_with_format(self): 200 | self.assertEqual(reverse('AccountRails#show', kwargs={'format': '.json'}), 201 | '/account-rails.json') 202 | 203 | def test_singleton_new(self): 204 | self.assertEqual(reverse('AccountRails#new'), '/account-rails/new') 205 | self.assert_resolves('/account-rails/new', resources.Account, 206 | methods=NEW_METHODS) 207 | 208 | def test_singleton_edit(self): 209 | self.assertEqual(reverse('AccountRails#edit'), '/account-rails/edit') 210 | self.assert_resolves('/account-rails/edit', resources.Account, 211 | methods=EDIT_METHODS) 212 | -------------------------------------------------------------------------------- /src/dagny/renderer.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from functools import wraps 4 | 5 | import odict 6 | 7 | from dagny import conneg 8 | 9 | 10 | class Skip(Exception): 11 | 12 | """ 13 | Move on to the next renderer backend. 14 | 15 | This exception can be raised by a renderer backend to instruct the 16 | `Renderer` to ignore the current backend and move on to the next-best one. 17 | """ 18 | 19 | 20 | class Renderer(object): 21 | 22 | """ 23 | Manage a collection of renderer backends, and their execution on an action. 24 | 25 | A renderer backend is a callable which accepts an `Action` and a `Resource` 26 | and returns an instance of `django.http.HttpResponse`. For example: 27 | 28 | >>> def render_html(action, resource): 29 | ... from django.http import HttpResponse 30 | ... return HttpResponse(content="...") 31 | 32 | Backends are associated with mimetypes on the `Renderer`, through mimetype 33 | shortcodes (see `dagny.conneg` for more information on shortcodes). The 34 | `Renderer` exports a dictionary-like interface for managing these 35 | associations: 36 | 37 | >>> r = Renderer() 38 | 39 | >>> r['html'] = render_html 40 | 41 | >>> r['html'] # doctest: +ELLIPSIS 42 | 43 | 44 | >>> 'html' in r 45 | True 46 | 47 | >>> del r['html'] 48 | 49 | >>> r['html'] 50 | Traceback (most recent call last): 51 | ... 52 | KeyError: 'html' 53 | 54 | >>> 'html' in r 55 | False 56 | 57 | A few helpful dictionary methods have also been added, albeit 58 | underscore-prefixed to prevent naming clashes. Behind the scenes, `Renderer` 59 | uses [odict](http://pypi.python.org/pypi/odict), which will keep the keys in 60 | the order they were *first* defined. Here are a few examples: 61 | 62 | >>> r['html'] = 1 63 | >>> r['json'] = 2 64 | >>> r['xml'] = 3 65 | 66 | >>> r._keys() 67 | ['html', 'json', 'xml'] 68 | 69 | >>> r._items() 70 | [('html', 1), ('json', 2), ('xml', 3)] 71 | 72 | >>> r._values() 73 | [1, 2, 3] 74 | 75 | This order preservation is useful for ConNeg, since you can define backends 76 | in order of server preference and the negotiator will consider them 77 | appropriately. You can push something to the end of the queue by removing 78 | and then re-adding it: 79 | 80 | >>> r['html'] = r._pop('html') 81 | 82 | >>> r._keys() 83 | ['json', 'xml', 'html'] 84 | 85 | You can also define backends using a handy decorator-based syntax: 86 | 87 | >>> @r.html 88 | ... def render_html_2(action, resource): 89 | ... from django.http import HttpResponse 90 | ... return HttpResponse(content="...") 91 | 92 | >>> r['html'] is render_html_2 93 | True 94 | 95 | Remember that your shortcode *must* be pre-registered with 96 | `dagny.conneg.MIMETYPES` for this to work, otherwise an `AttributeError` 97 | will be raised. This also introduces the constraint that your shortcode must 98 | be a valid Python identifier. 99 | """ 100 | 101 | def __init__(self, backends=None): 102 | if backends is None: 103 | backends = odict.odict() 104 | else: 105 | backends = backends.copy() 106 | self._backends = backends 107 | 108 | def __getattr__(self, shortcode): 109 | 110 | """ 111 | Support use of decorator syntax to define new renderer backends. 112 | 113 | >>> r = Renderer() 114 | 115 | >>> @r.html 116 | ... def render_html(action, resource): 117 | ... return "..." 118 | 119 | >>> render_html # doctest: +ELLIPSIS 120 | 121 | 122 | >>> r['html'] # doctest: +ELLIPSIS 123 | 124 | 125 | >>> r['html'] is render_html 126 | True 127 | 128 | """ 129 | 130 | if shortcode not in conneg.MIMETYPES: 131 | raise AttributeError(shortcode) 132 | 133 | def decorate(function): 134 | self[shortcode] = function 135 | return function 136 | return decorate 137 | 138 | def __call__(self, action, resource, *args, **kwargs): 139 | matches = self._match(action, resource) 140 | 141 | for shortcode in matches: 142 | try: 143 | return self[shortcode](action, resource, *args, **kwargs) 144 | except Skip: 145 | continue 146 | 147 | # One last-ditch attempt to render HTML, pursuant to the note about 148 | # HTTP/1.1 here: 149 | # 150 | # It's better to give an 'unacceptable' response than none at all. 151 | if 'html' not in matches and 'html' in self: 152 | try: 153 | return self['html'](action, resource, *args, **kwargs) 154 | except Skip: 155 | pass 156 | 157 | return not_acceptable(action, resource) 158 | 159 | def _match(self, action, resource): 160 | """Return all matching shortcodes for a given action and resource.""" 161 | 162 | matches = [] 163 | 164 | format_override = resource._format() 165 | if format_override and (format_override in self._keys()): 166 | matches.append(format_override) 167 | 168 | accept_header = resource.request.META.get('HTTP_ACCEPT') 169 | if accept_header: 170 | matches.extend(conneg.match_accept(accept_header, self._keys())) 171 | 172 | if (not matches) and ('html' in self): 173 | matches.append('html') 174 | 175 | return matches 176 | 177 | def _bind(self, action): 178 | 179 | """ 180 | Bind this `Renderer` to an action, returning a `BoundRenderer`. 181 | 182 | >>> r = Renderer() 183 | >>> action = object() 184 | >>> r['html'] = 1 185 | 186 | >>> br = r._bind(action) 187 | >>> br # doctest: +ELLIPSIS 188 | > 189 | 190 | Associations should be preserved, albeit on a copied `odict`, so that 191 | modifications to the `BoundRenderer` do not propagate back to this. 192 | 193 | >>> br['html'] 194 | 1 195 | >>> br['html'] = 2 196 | >>> br['html'] 197 | 2 198 | >>> r['html'] 199 | 1 200 | >>> r['html'] = 3 201 | >>> r['html'] 202 | 3 203 | >>> br['html'] 204 | 2 205 | 206 | """ 207 | 208 | return BoundRenderer(action, backends=self._backends) 209 | 210 | def _copy(self): 211 | return type(self)(backends=self._backends) 212 | 213 | ### 214 | # 215 | # This chunk of code creates several proxy methods going through to 216 | # `_backends`. A group of them are underscore-prefixed to prevent naming 217 | # clashes with the `__getattr__`-based decorator syntax (so you could 218 | # still associate a backend with a shortcode of 'pop', for example). 219 | 220 | proxy = lambda meth: property(lambda self: getattr(self._backends, meth)) 221 | 222 | for method in ('__contains__', '__getitem__', '__setitem__', '__delitem__'): 223 | vars()[method] = proxy(method) 224 | 225 | for method in ('clear', 'get', 'items', 'iteritems', 'iterkeys', 226 | 'itervalues', 'keys', 'pop', 'popitem', 'ritems', 227 | 'riteritems', 'riterkeys', 'ritervalues', 'rkeys', 'rvalues', 228 | 'setdefault', 'sort', 'update', 'values'): 229 | vars()['_' + method] = proxy(method) 230 | 231 | _dict = proxy('as_dict') 232 | 233 | del method, proxy 234 | 235 | # 236 | ### 237 | 238 | 239 | class BoundRenderer(Renderer): 240 | 241 | def __init__(self, action, backends=None): 242 | super(BoundRenderer, self).__init__(backends=backends) 243 | self._action = action 244 | 245 | def __repr__(self): 246 | return "" % (self._action,) 247 | 248 | def __getattr__(self, shortcode): 249 | 250 | """ 251 | Support use of decorator syntax to define new renderer backends. 252 | 253 | In this case, decorated functions should be methods which operate on a 254 | resource, and take no other arguments. 255 | 256 | >>> action = object() 257 | >>> r = BoundRenderer(action) 258 | >>> old_action_id = id(action) 259 | 260 | >>> @r.html 261 | ... def action(resource): 262 | ... return "..." 263 | 264 | >>> id(action) == old_action_id # Object has not changed. 265 | True 266 | 267 | Functions will be wrapped internally, so that their function signature 268 | is that of a generic renderer backend. Accessing the 269 | 270 | >>> resource = object() 271 | >>> r['html'](action, resource) 272 | '...' 273 | 274 | """ 275 | 276 | if shortcode not in conneg.MIMETYPES: 277 | raise AttributeError(shortcode) 278 | 279 | def decorate(method): 280 | self[shortcode] = resource_method_wrapper(method) 281 | return self._action 282 | return decorate 283 | 284 | def __call__(self, resource, *args, **kwargs): 285 | return super(BoundRenderer, self).__call__(self._action, resource, 286 | *args, **kwargs) 287 | 288 | 289 | def resource_method_wrapper(method): 290 | 291 | """ 292 | Wrap a 0-ary resource method as a generic renderer backend. 293 | 294 | >>> @resource_method_wrapper 295 | ... def func(resource): 296 | ... print repr(resource) 297 | 298 | >>> action = "abc" 299 | >>> resource = "def" 300 | 301 | >>> func(action, resource) 302 | 'def' 303 | 304 | """ 305 | 306 | def generic_renderer_backend(action, resource): 307 | return method(resource) 308 | return generic_renderer_backend 309 | 310 | 311 | def not_acceptable(action, resource): 312 | """Respond, indicating that no acceptable entity could be generated.""" 313 | 314 | from django.http import HttpResponse 315 | 316 | response = HttpResponse(status=406) # Not Acceptable 317 | del response['Content-Type'] 318 | return response 319 | -------------------------------------------------------------------------------- /doc/uris.md: -------------------------------------------------------------------------------- 1 | # URIs 2 | 3 | This document describes in depth Dagny’s default URI scheme for resources. In 4 | this aspect, Dagny follows the Rails convention, since it is well-established 5 | and familiar to many developers. 6 | 7 | You can also skip to the [URLconf reference](#urlconf). 8 | 9 | 10 | ## Types of Resource 11 | 12 | Dagny supports two types of resource: collections and singular resources. 13 | Collections are lists of uniquely-identifiable members, such as blog posts, 14 | users and products. Singular resources (a.k.a. singletons) are resources of 15 | which only one ever exists, and are typically tied to the currently logged-in 16 | user (e.g. the current user's profile, the user session, etc.). 17 | 18 | 19 | ## The Default URL Scheme 20 | 21 | What follows is the default URL scheme for resources. Dagny also supports 22 | configurable [URL styles](#alternative_url_styles), but you should get familiar 23 | with the defaults before moving on to those. 24 | 25 | 26 | ### Collections 27 | 28 | The paths and their interaction with the standard HTTP methods are as follows: 29 | 30 | Name | Path | Method | Action | Behavior 31 | ---------- | ---------------- | -------- | --------- | -------------------------------- 32 | Collection | `/users/` | `GET` | `index` | List all users 33 | | | `POST` | `create` | Create a user 34 | Member | `/users/1/` | `GET` | `show` | Display user 1 35 | | | `PUT` | `update` | Edit user 1 36 | | | `POST` | `update` | Edit user 1 37 | | | `DELETE` | `destroy` | Delete user 1 38 | New | `/users/new/` | `GET` | `new` | Display the new user form 39 | Edit | `/users/1/edit/` | `GET` | `edit` | Display the edit form for user 1 40 | 41 | Note that not all of these actions are required; for example, you may not wish 42 | to provide `/users/new` and `/users/1/edit`, instead preferring to display the 43 | relevant forms under `/users/` and `/users/1/`. You may also support only 44 | certain HTTP methods at a given path; for example, only allowing `GET` on 45 | `/users/1/`. 46 | 47 | To work around the fact that `PUT` and `DELETE` are not typically supported in 48 | browsers, you can add a `_method` parameter to a `POST` form to override the 49 | request method: 50 | 51 | :::html+django 52 |
53 | 54 | ... 55 |
56 | 57 | 58 | ### Singular Resources 59 | 60 | Name | Path | Method | Action | Behavior 61 | ------ | ---------------- | -------- | ----------------- | ----------------------------- 62 | Member | `/account/` | `GET` | `Account.show` | Display the account 63 | | | `POST` | `Account.create` | Create the new account 64 | | | `PUT` | `Account.update` | Update the account 65 | | | `DELETE` | `Account.destroy` | Delete the account 66 | New | `/account/new/` | `GET` | `Account.new` | Display the new account form 67 | Edit | `/account/edit/` | `GET` | `Account.edit` | Display the edit account form 68 | 69 | The same point applies here: you don’t need to specify all of these actions 70 | every time. 71 | 72 | 73 | ## The URLconf 74 | 75 | ### Collections 76 | 77 | Pointing to a collection resource from your URLconf is relatively simple: 78 | 79 | #!python 80 | from dagny.urls import resources # plural! 81 | from django.conf.urls.defaults import * 82 | 83 | urlpatterns = patterns('', 84 | (r'^users/', resources('myapp.resources.User')) 85 | ) 86 | 87 | You can customize this; for example, to use a slug/username instead of a numeric 88 | ID: 89 | 90 | :::python 91 | urlpatterns = patterns('', 92 | (r'^users/', resources('myapp.resources.User', 93 | id=r'[\w\-_]+')), 94 | ) 95 | 96 | If you'd like the ID to appear as a named group in the regex (and hence be 97 | passed to your resource in `self.params` instead of as a positional argument), 98 | pass it as a two-tuple of `(param_name, regex)`: 99 | 100 | :::python 101 | urlpatterns = patterns('', 102 | (r'^users/', resources('myapp.resources.User', 103 | id=('username', r'[\w\-_]+'))), 104 | ) 105 | 106 | This is especially useful if your URL already captures a parameter, as Django 107 | does not support mixing positional and keyword arguments in a URL. 108 | 109 | You can also restrict the actions that are routed to. Pass the `actions` keyword 110 | argument to specify which of these you would like to be available: 111 | 112 | :::python 113 | urlpatterns = patterns('', 114 | (r'^users/', resources('myapp.resources.User', 115 | actions=('index', 'show', 'create', 'update', 'destroy'))), 116 | ) 117 | 118 | This is useful if you’re going to display the `new` and `edit` forms on the 119 | `index` and `show` pages, for example. Excluding `new` and `edit` may also 120 | prevent naming clashes if you’re using slug identifiers in URIs. 121 | 122 | **N.B.:** most of the time, you won't need to use the `actions` keyword 123 | argument; if you just leave actions undefined, Dagny will automatically return 124 | the appropriate responses. The only case where `actions` would be useful is if 125 | those actions *are* defined on the resource but you don't want routes to them 126 | to be created. 127 | 128 | 129 | ### Singular Resources 130 | 131 | For this, use the `resource()` helper: 132 | 133 | #!python 134 | from dagny.urls import resource # singular! 135 | from django.conf.urls.defaults import * 136 | 137 | urlpatterns = patterns('', 138 | (r'^account/', resource('myapp.resources.User')) 139 | ) 140 | 141 | `resource()` is similar to `resources()`, but it only generates `show`, `new` 142 | and `edit`, and doesn’t take an `id` parameter. 143 | 144 | 145 | ## Reversing URLs 146 | 147 | `resource()` and `resources()` both attach names to the patterns they generate. 148 | This allows you to use the `{% url %}` templatetag, for example: 149 | 150 | :::html+django 151 | 152 |
153 | ... 154 |
155 | 156 | 157 | Sign Up! 158 | 159 | 160 | View user 161 | 162 | 163 | Edit user 164 | 165 | 166 |
167 | ... 168 |
169 | 170 | You can also use these references in `get_absolute_url()` methods that have been 171 | wrapped with `@models.permalink`: 172 | 173 | :::python 174 | from django.db import models 175 | 176 | class User(models.Model): 177 | # ... snip! ... 178 | 179 | @models.permalink 180 | def get_absolute_url(self): 181 | return ("myapp.resources.User#show", self.id) 182 | 183 | Of course, having to write out the full path to the resource is quite 184 | cumbersome, so you can give a `name` keyword argument to either of the URL 185 | helpers, and use the shortcut: 186 | 187 | :::python 188 | # In urls.py: 189 | urlpatterns = patterns('', 190 | (r'^users/', resources('myapp.resources.User', name='User')) 191 | ) 192 | 193 | # In models.py: 194 | class User(models.Model): 195 | @models.permalink 196 | def get_absolute_url(self): 197 | return ("User#show", self.id) 198 | 199 | # In resources.py: 200 | class User(Resource): 201 | # ... snip! ... 202 | @action 203 | def update(self, user_id): 204 | # ... validate the form and save the user ... 205 | return redirect("User#show", self.user.id) 206 | 207 | These shortcuts are also available in the templates: 208 | 209 | :::html+django 210 |
211 | ... 212 |
213 | 214 | Sign Up! 215 | 216 | View user 217 | 218 | Edit user 219 | 220 |
221 | ... 222 |
223 | 224 | 225 | ## Alternative URL Styles 226 | 227 | Dagny supports configurable *URL styles*, of which the default is only a single 228 | instance. Following are the other two which Dagny comes packaged with. 229 | 230 | To use an alternative style, create a `dagny.urls.router.URLRouter` with the 231 | style and use the `resources()` and `resource()` methods defined on that as 232 | your URLconf helpers. 233 | 234 | :::python 235 | from dagny.urls.router import URLRouter 236 | from myapp import MyURLStyle 237 | 238 | style = MyURLStyle() 239 | router = URLRouter(style) 240 | 241 | # Use these instead when defining your URLs. 242 | resources, resource = router.resources, router.resource 243 | 244 | The built-in alternative styles already have stub modules with the two helpers 245 | defined. 246 | 247 | 248 | ### AtomPub URLs 249 | 250 | This style matches the URL conventions used in the 251 | [Atom Publishing Protocol][app]. 252 | 253 | [app]: http://tools.ietf.org/html/rfc5023 254 | 255 | Usage: `from dagny.urls.atompub import *` 256 | 257 | Path | Action(s) 258 | ------------------ | ---------------------------------------------- 259 | `/accounts/` | `Account.index`, `Account.create` 260 | `/accounts/new` | `Account.new` 261 | `/accounts/1` | `Account.show`, `Account.update`, `Account.destroy` 262 | `/accounts/1/edit` | `Account.edit` 263 | 264 | 265 | ### Rails URLs 266 | 267 | These URLs mimic those of Rails, including the optional format extensions. 268 | 269 | Usage: `from dagny.urls.rails import *` 270 | 271 | Path | Action(s) 272 | ------------------ | ---------------------------------------------- 273 | `/accounts` | `Account.index`, `Account.create` 274 | `/accounts.json` | 〃 (with kwargs `{'format': 'json'}`) 275 | `/accounts/` | 〃 276 | `/accounts/new` | `Account.new` 277 | `/accounts/1` | `Account.show`, `Account.update`, `Account.destroy` 278 | `/accounts/1/` | 〃 279 | `/accounts/1.json` | 〃 (with kwargs `{'format': 'json'}`) 280 | `/accounts/1/edit` | `Account.edit` 281 | 282 | **Note**: due to limitations of the URLconf system—to wit, the inability to mix 283 | keyword and positional arguments in the same URL—your IDs/slugs have to come 284 | in as named parameters. By default, the parameter will be called `id`, but you 285 | can select a different one using the `id` keyword argument to the URL helpers: 286 | 287 | :::python 288 | urlpatterns = patterns('', 289 | # To get the slug, use `self.params['slug']` on the `Post` resource. 290 | # Also note: there is no terminating slash on the top-level regex. 291 | (r'posts', resources('myapp.resources.Post', name='Post', 292 | id=('slug', r'[\w\-]+'))), 293 | ) 294 | 295 | Another caveat: do not terminate your top-level regex with a slash, or the 296 | format extension on the resource index (e.g. `/posts.json`) won't work. 297 | -------------------------------------------------------------------------------- /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.13" 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 | def _no_sandbox(function): 207 | def __no_sandbox(*args, **kw): 208 | try: 209 | from setuptools.sandbox import DirectorySandbox 210 | if not hasattr(DirectorySandbox, '_old'): 211 | def violation(*args): 212 | pass 213 | DirectorySandbox._old = DirectorySandbox._violation 214 | DirectorySandbox._violation = violation 215 | patched = True 216 | else: 217 | patched = False 218 | except ImportError: 219 | patched = False 220 | 221 | try: 222 | return function(*args, **kw) 223 | finally: 224 | if patched: 225 | DirectorySandbox._violation = DirectorySandbox._old 226 | del DirectorySandbox._old 227 | 228 | return __no_sandbox 229 | 230 | def _patch_file(path, content): 231 | """Will backup the file then patch it""" 232 | existing_content = open(path).read() 233 | if existing_content == content: 234 | # already patched 235 | log.warn('Already patched.') 236 | return False 237 | log.warn('Patching...') 238 | _rename_path(path) 239 | f = open(path, 'w') 240 | try: 241 | f.write(content) 242 | finally: 243 | f.close() 244 | return True 245 | 246 | _patch_file = _no_sandbox(_patch_file) 247 | 248 | def _same_content(path, content): 249 | return open(path).read() == content 250 | 251 | def _rename_path(path): 252 | new_name = path + '.OLD.%s' % time.time() 253 | log.warn('Renaming %s into %s', path, new_name) 254 | os.rename(path, new_name) 255 | return new_name 256 | 257 | def _remove_flat_installation(placeholder): 258 | if not os.path.isdir(placeholder): 259 | log.warn('Unkown installation at %s', placeholder) 260 | return False 261 | found = False 262 | for file in os.listdir(placeholder): 263 | if fnmatch.fnmatch(file, 'setuptools*.egg-info'): 264 | found = True 265 | break 266 | if not found: 267 | log.warn('Could not locate setuptools*.egg-info') 268 | return 269 | 270 | log.warn('Removing elements out of the way...') 271 | pkg_info = os.path.join(placeholder, file) 272 | if os.path.isdir(pkg_info): 273 | patched = _patch_egg_dir(pkg_info) 274 | else: 275 | patched = _patch_file(pkg_info, SETUPTOOLS_PKG_INFO) 276 | 277 | if not patched: 278 | log.warn('%s already patched.', pkg_info) 279 | return False 280 | # now let's move the files out of the way 281 | for element in ('setuptools', 'pkg_resources.py', 'site.py'): 282 | element = os.path.join(placeholder, element) 283 | if os.path.exists(element): 284 | _rename_path(element) 285 | else: 286 | log.warn('Could not find the %s element of the ' 287 | 'Setuptools distribution', element) 288 | return True 289 | 290 | _remove_flat_installation = _no_sandbox(_remove_flat_installation) 291 | 292 | def _after_install(dist): 293 | log.warn('After install bootstrap.') 294 | placeholder = dist.get_command_obj('install').install_purelib 295 | _create_fake_setuptools_pkg_info(placeholder) 296 | 297 | def _create_fake_setuptools_pkg_info(placeholder): 298 | if not placeholder or not os.path.exists(placeholder): 299 | log.warn('Could not find the install location') 300 | return 301 | pyver = '%s.%s' % (sys.version_info[0], sys.version_info[1]) 302 | setuptools_file = 'setuptools-%s-py%s.egg-info' % \ 303 | (SETUPTOOLS_FAKED_VERSION, pyver) 304 | pkg_info = os.path.join(placeholder, setuptools_file) 305 | if os.path.exists(pkg_info): 306 | log.warn('%s already exists', pkg_info) 307 | return 308 | 309 | log.warn('Creating %s', pkg_info) 310 | f = open(pkg_info, 'w') 311 | try: 312 | f.write(SETUPTOOLS_PKG_INFO) 313 | finally: 314 | f.close() 315 | 316 | pth_file = os.path.join(placeholder, 'setuptools.pth') 317 | log.warn('Creating %s', pth_file) 318 | f = open(pth_file, 'w') 319 | try: 320 | f.write(os.path.join(os.curdir, setuptools_file)) 321 | finally: 322 | f.close() 323 | 324 | _create_fake_setuptools_pkg_info = _no_sandbox(_create_fake_setuptools_pkg_info) 325 | 326 | def _patch_egg_dir(path): 327 | # let's check if it's already patched 328 | pkg_info = os.path.join(path, 'EGG-INFO', 'PKG-INFO') 329 | if os.path.exists(pkg_info): 330 | if _same_content(pkg_info, SETUPTOOLS_PKG_INFO): 331 | log.warn('%s already patched.', pkg_info) 332 | return False 333 | _rename_path(path) 334 | os.mkdir(path) 335 | os.mkdir(os.path.join(path, 'EGG-INFO')) 336 | pkg_info = os.path.join(path, 'EGG-INFO', 'PKG-INFO') 337 | f = open(pkg_info, 'w') 338 | try: 339 | f.write(SETUPTOOLS_PKG_INFO) 340 | finally: 341 | f.close() 342 | return True 343 | 344 | _patch_egg_dir = _no_sandbox(_patch_egg_dir) 345 | 346 | def _before_install(): 347 | log.warn('Before install bootstrap.') 348 | _fake_setuptools() 349 | 350 | 351 | def _under_prefix(location): 352 | if 'install' not in sys.argv: 353 | return True 354 | args = sys.argv[sys.argv.index('install')+1:] 355 | for index, arg in enumerate(args): 356 | for option in ('--root', '--prefix'): 357 | if arg.startswith('%s=' % option): 358 | top_dir = arg.split('root=')[-1] 359 | return location.startswith(top_dir) 360 | elif arg == option: 361 | if len(args) > index: 362 | top_dir = args[index+1] 363 | return location.startswith(top_dir) 364 | if arg == '--user' and USER_SITE is not None: 365 | return location.startswith(USER_SITE) 366 | return True 367 | 368 | 369 | def _fake_setuptools(): 370 | log.warn('Scanning installed packages') 371 | try: 372 | import pkg_resources 373 | except ImportError: 374 | # we're cool 375 | log.warn('Setuptools or Distribute does not seem to be installed.') 376 | return 377 | ws = pkg_resources.working_set 378 | try: 379 | setuptools_dist = ws.find(pkg_resources.Requirement.parse('setuptools', 380 | replacement=False)) 381 | except TypeError: 382 | # old distribute API 383 | setuptools_dist = ws.find(pkg_resources.Requirement.parse('setuptools')) 384 | 385 | if setuptools_dist is None: 386 | log.warn('No setuptools distribution found') 387 | return 388 | # detecting if it was already faked 389 | setuptools_location = setuptools_dist.location 390 | log.warn('Setuptools installation detected at %s', setuptools_location) 391 | 392 | # if --root or --preix was provided, and if 393 | # setuptools is not located in them, we don't patch it 394 | if not _under_prefix(setuptools_location): 395 | log.warn('Not patching, --root or --prefix is installing Distribute' 396 | ' in another location') 397 | return 398 | 399 | # let's see if its an egg 400 | if not setuptools_location.endswith('.egg'): 401 | log.warn('Non-egg installation') 402 | res = _remove_flat_installation(setuptools_location) 403 | if not res: 404 | return 405 | else: 406 | log.warn('Egg installation') 407 | pkg_info = os.path.join(setuptools_location, 'EGG-INFO', 'PKG-INFO') 408 | if (os.path.exists(pkg_info) and 409 | _same_content(pkg_info, SETUPTOOLS_PKG_INFO)): 410 | log.warn('Already patched.') 411 | return 412 | log.warn('Patching...') 413 | # let's create a fake egg replacing setuptools one 414 | res = _patch_egg_dir(setuptools_location) 415 | if not res: 416 | return 417 | log.warn('Patched done.') 418 | _relaunch() 419 | 420 | 421 | def _relaunch(): 422 | log.warn('Relaunching...') 423 | # we have to relaunch the process 424 | # pip marker to avoid a relaunch bug 425 | if sys.argv[:3] == ['-c', 'install', '--single-version-externally-managed']: 426 | sys.argv[0] = 'setup.py' 427 | args = [sys.executable] + sys.argv 428 | sys.exit(subprocess.call(args)) 429 | 430 | 431 | def _extractall(self, path=".", members=None): 432 | """Extract all members from the archive to the current working 433 | directory and set owner, modification time and permissions on 434 | directories afterwards. `path' specifies a different directory 435 | to extract to. `members' is optional and must be a subset of the 436 | list returned by getmembers(). 437 | """ 438 | import copy 439 | import operator 440 | from tarfile import ExtractError 441 | directories = [] 442 | 443 | if members is None: 444 | members = self 445 | 446 | for tarinfo in members: 447 | if tarinfo.isdir(): 448 | # Extract directories with a safe mode. 449 | directories.append(tarinfo) 450 | tarinfo = copy.copy(tarinfo) 451 | tarinfo.mode = 448 # decimal for oct 0700 452 | self.extract(tarinfo, path) 453 | 454 | # Reverse sort directories. 455 | if sys.version_info < (2, 4): 456 | def sorter(dir1, dir2): 457 | return cmp(dir1.name, dir2.name) 458 | directories.sort(sorter) 459 | directories.reverse() 460 | else: 461 | directories.sort(key=operator.attrgetter('name'), reverse=True) 462 | 463 | # Set correct owner, mtime and filemode on directories. 464 | for tarinfo in directories: 465 | dirpath = os.path.join(path, tarinfo.name) 466 | try: 467 | self.chown(tarinfo, dirpath) 468 | self.utime(tarinfo, dirpath) 469 | self.chmod(tarinfo, dirpath) 470 | except ExtractError: 471 | e = sys.exc_info()[1] 472 | if self.errorlevel > 1: 473 | raise 474 | else: 475 | self._dbg(1, "tarfile: %s" % e) 476 | 477 | 478 | def main(argv, version=DEFAULT_VERSION): 479 | """Install or upgrade setuptools and EasyInstall""" 480 | tarball = download_setuptools() 481 | _install(tarball) 482 | 483 | 484 | if __name__ == '__main__': 485 | main(sys.argv[1:]) 486 | --------------------------------------------------------------------------------