├── 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 |
12 | {% endblock %}
13 |
--------------------------------------------------------------------------------
/doc/.templates/base.html:
--------------------------------------------------------------------------------
1 | {% extends "markdoc-default/base.html" %}
2 |
3 | {% block body %}
4 |
5 |
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 |
12 |
13 |
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_('
103 |
104 |
105 | Name: {{ self.poll.name }}
106 | Edit this poll
107 |
108 |
109 |
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 | - {{ poll.name }}
101 | {% endfor %}
102 |
103 | Create a poll
104 |
105 |
106 |
111 |
112 |
113 | Name: {{ self.poll.name }}
114 | Edit this poll
115 |
116 |
117 |
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 |
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 |
155 |
156 |
157 | Sign Up!
158 |
159 |
160 | View user
161 |
162 |
163 | Edit user
164 |
165 |
166 |
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 |
213 |
214 | Sign Up!
215 |
216 | View user
217 |
218 | Edit user
219 |
220 |
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 |
--------------------------------------------------------------------------------