Sphinx docs, examples, lots of refactoring

This commit is contained in:
tom christie tom@tomchristie.com 2011-01-23 23:08:16 +00:00
parent 9979903272
commit 4100242fa2
36 changed files with 2240 additions and 2 deletions

View File

@ -3,7 +3,7 @@ syntax: glob
*.pyc *.pyc
*.db *.db
env env
cache docs-build
html html
.project .project
.pydevproject .pydevproject

View File

@ -13,7 +13,9 @@
import sys, os import sys, os
sys.path.append(os.path.join(os.path.dirname(os.path.dirname(__file__)), 'src')) sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(__file__)), 'flywheel'))
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(__file__)), 'examples'))
import settings import settings
from django.core.management import setup_environ from django.core.management import setup_environ
setup_environ(settings) setup_environ(settings)
@ -57,6 +59,7 @@ version = '0.1'
# The full version, including alpha/beta/rc tags. # The full version, including alpha/beta/rc tags.
release = '0.1' release = '0.1'
autodoc_member_order='bysource'
# The language for content autogenerated by Sphinx. Refer to documentation # The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages. # for a list of supported languages.
#language = None #language = None

5
docs/emitters.rst Normal file
View File

@ -0,0 +1,5 @@
Emitters
========
.. automodule:: emitters
:members:

View File

@ -3,6 +3,20 @@ FlyWheel Documentation
This is the online documentation for FlyWheel - A REST framework for Django. This is the online documentation for FlyWheel - A REST framework for Django.
* Clean, simple, class-based views for Resources.
* Easy input validation using Forms and ModelForms.
* Self describing APIs, with HTML and Plain Text outputs.
.. toctree::
:maxdepth: 1
resource
modelresource
parsers
emitters
response
Indices and tables Indices and tables
------------------ ------------------

5
docs/modelresource.rst Normal file
View File

@ -0,0 +1,5 @@
ModelResource
=============
.. automodule:: modelresource
:members:

5
docs/parsers.rst Normal file
View File

@ -0,0 +1,5 @@
Parsers
=======
.. automodule:: parsers
:members:

125
docs/resource.rst Normal file
View File

@ -0,0 +1,125 @@
:mod:`resource`
===============
The :mod:`resource` module is the core of FlyWheel. It provides the :class:`Resource` base class which handles incoming HTTP requests and maps them to method calls, performing authentication, input deserialization, input validation, output serialization.
Resources are created by sublassing :class:`Resource`, setting a number of class attributes, and overriding one or more methods.
:class:`Resource` class attributes
----------------------------------
The following class attributes determine the behavior of the Resource and are intended to be overridden.
.. attribute:: Resource.allowed_methods
A list of the HTTP methods that the Resource supports.
HTTP requests to the resource that do not map to an allowed operation will result in a 405 Method Not Allowed response.
Default: ``('GET',)``
.. attribute:: Resource.anon_allowed_methods
A list of the HTTP methods that the Resource supports for unauthenticated users.
Unauthenticated HTTP requests to the resource that do not map to an allowed operation will result in a 405 Method Not Allowed response.
Default: ``()``
.. attribute:: Resource.emitters
Lists the set of emitters that the Resource supports. This determines which media types the resource can serialize it's output to. Clients can specify which media types they accept using standard HTTP content negotiation via the Accept header. (See `RFC 2616 - Sec 14.1 <http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html>`_) Clients can also override this standard content negotiation by specifying a `_format` ...
The :mod:`emitters` module provides the :class:`BaseEmitter` class and a set of default emitters, including emitters for JSON and XML, as well as emitters for HTML and Plain Text which provide for a self documenting API.
The ordering of the Emitters is important as it determines an order of preference.
Default: ``(emitters.JSONEmitter, emitters.DocumentingHTMLEmitter, emitters.DocumentingXHTMLEmitter, emitters.DocumentingPlainTextEmitter, emitters.XMLEmitter)``
.. attribute:: Resource.parsers
Lists the set of parsers that the Resource supports. This determines which media types the resource can accept as input for incoming HTTP requests. (Typically PUT and POST requests).
The ordering of the Parsers may be considered informative of preference but is not used ...
Default: ``(parsers.JSONParser, parsers.XMLParser, parsers.FormParser)``
.. attribute:: Resource.form
If not None, this attribute should be a Django form which will be used to validate any request data.
This attribute is typically only used for POST or PUT requests to the resource.
Deafult: ``None``
.. attribute:: Resource.callmap
Maps HTTP methods to function calls on the :class:`Resource`. It may be overridden in order to add support for other HTTP methods such as HEAD, OPTIONS and PATCH, or in order to map methods to different function names, for example to use a more `CRUD <http://en.wikipedia.org/wiki/Create,_read,_update_and_delete>`_ like style.
Default: ``{ 'GET': 'get', 'POST': 'post', 'PUT': 'put', 'DELETE': 'delete' }``
:class:`Resource` methods
-------------------------
.. method:: Resource.get
.. method:: Resource.post
.. method:: Resource.put
.. method:: Resource.delete
.. method:: Resource.authenticate
.. method:: Resource.reverse
:class:`Resource` properties
----------------------------
.. method:: Resource.name
.. method:: Resource.description
.. method:: Resource.default_emitter
.. method:: Resource.default_parser
.. method:: Resource.emitted_media_types
.. method:: Resource.parsed_media_types
:class:`Resource` reserved parameters
-------------------------------------
.. attribute:: Resource.ACCEPT_QUERY_PARAM
If set, allows the default `Accept:` header content negotiation to be bypassed by setting the requested media type in a query parameter on the URL. This can be useful if it is necessary to be able to hyperlink to a given format on the Resource using standard HTML.
Set to None to disable, or to another string value to use another name for the reserved URL query parameter.
Default: ``_accept``
.. attribute:: Resource.METHOD_PARAM
If set, allows for PUT and DELETE requests to be tunneled on form POST operations, by setting a (typically hidden) form field with the method name. This allows standard HTML forms to perform method requests which would otherwise `not be supported <http://dev.w3.org/html5/spec/Overview.html#attr-fs-method>`_
Set to None to disable, or to another string value to use another name for the reserved form field.
Default: ``_method``
.. attribute:: Resource.CONTENTTYPE_PARAM
Used together with :attr:`CONTENT_PARAM`.
If set, allows for arbitrary content types to be tunneled on form POST operations, by setting a form field with the content type. This allows standard HTML forms to perform requests with content types other those `supported by default <http://dev.w3.org/html5/spec/Overview.html#attr-fs-enctype>`_ (ie. `application/x-www-form-urlencoded`, `multipart/form-data`, and `text-plain`)
Set to None to disable, or to another string value to use another name for the reserved form field.
Default: ``_contenttype``
.. attribute:: Resource.CONTENT_PARAM
Used together with :attr:`CONTENTTYPE_PARAM`.
Set to None to disable, or to another string value to use another name for the reserved form field.
Default: ``_content``
.. attribute:: Resource.CSRF_PARAM
The name used in Django's (typically hidden) form field for `CSRF Protection <http://docs.djangoproject.com/en/dev/ref/contrib/csrf/>`_.
Setting to None does not disable Django's CSRF middleware, but it does mean that the field name will not be treated as reserved by FlyWheel, so for example the default :class:`FormParser` will return fields with this as part of the request content, rather than ignoring them.
Default:: ``csrfmiddlewaretoken``
reserved params
internal methods

5
docs/response.rst Normal file
View File

@ -0,0 +1,5 @@
Response
========
.. automodule:: response
:members:

0
examples/__init__.py Normal file
View File

View File

View File

@ -0,0 +1,68 @@
from django.db import models
from django.template.defaultfilters import slugify
import uuid
def uuid_str():
return str(uuid.uuid1())
RATING_CHOICES = ((0, 'Awful'),
(1, 'Poor'),
(2, 'OK'),
(3, 'Good'),
(4, 'Excellent'))
class BlogPost(models.Model):
key = models.CharField(primary_key=True, max_length=64, default=uuid_str, editable=False)
title = models.CharField(max_length=128)
content = models.TextField()
created = models.DateTimeField(auto_now_add=True)
slug = models.SlugField(editable=False, default='')
class Meta:
ordering = ('created',)
@models.permalink
def get_absolute_url(self):
return ('blogpost.views.BlogPostInstance', (), {'key': self.key})
@property
@models.permalink
def comments_url(self):
"""Link to a resource which lists all comments for this blog post."""
return ('blogpost.views.CommentList', (), {'blogpost_id': self.key})
@property
@models.permalink
def comment_url(self):
"""Link to a resource which can create a comment for this blog post."""
return ('blogpost.views.CommentCreator', (), {'blogpost_id': self.key})
def __unicode__(self):
return self.title
def save(self, *args, **kwargs):
self.slug = slugify(self.title)
super(self.__class__, self).save(*args, **kwargs)
class Comment(models.Model):
blogpost = models.ForeignKey(BlogPost, editable=False, related_name='comments')
username = models.CharField(max_length=128)
comment = models.TextField()
rating = models.IntegerField(blank=True, null=True, choices=RATING_CHOICES, help_text='How did you rate this post?')
created = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ('created',)
@models.permalink
def get_absolute_url(self):
return ('blogpost.views.CommentInstance', (), {'blogpost': self.blogpost.key, 'id': self.id})
@property
@models.permalink
def blogpost_url(self):
"""Link to the blog post resource which this comment corresponds to."""
return ('blogpost.views.BlogPostInstance', (), {'key': self.blogpost.key})

163
examples/blogpost/tests.py Normal file
View File

@ -0,0 +1,163 @@
"""Test a range of REST API usage of the example application.
"""
from django.test import TestCase
from django.core.urlresolvers import reverse
from blogpost import views
#import json
#from rest.utils import xml2dict, dict2xml
class AcceptHeaderTests(TestCase):
"""Test correct behaviour of the Accept header as specified by RFC 2616:
http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.1"""
def assert_accept_mimetype(self, mimetype, expect=None):
"""Assert that a request with given mimetype in the accept header,
gives a response with the appropriate content-type."""
if expect is None:
expect = mimetype
resp = self.client.get(reverse(views.RootResource), HTTP_ACCEPT=mimetype)
self.assertEquals(resp['content-type'], expect)
def test_accept_json(self):
"""Ensure server responds with Content-Type of JSON when requested."""
self.assert_accept_mimetype('application/json')
def test_accept_xml(self):
"""Ensure server responds with Content-Type of XML when requested."""
self.assert_accept_mimetype('application/xml')
def test_accept_json_when_prefered_to_xml(self):
"""Ensure server responds with Content-Type of JSON when it is the client's prefered choice."""
self.assert_accept_mimetype('application/json;q=0.9, application/xml;q=0.1', expect='application/json')
def test_accept_xml_when_prefered_to_json(self):
"""Ensure server responds with Content-Type of XML when it is the client's prefered choice."""
self.assert_accept_mimetype('application/json;q=0.1, application/xml;q=0.9', expect='application/xml')
def test_default_json_prefered(self):
"""Ensure server responds with JSON in preference to XML."""
self.assert_accept_mimetype('application/json,application/xml', expect='application/json')
def test_accept_generic_subtype_format(self):
"""Ensure server responds with an appropriate type, when the subtype is left generic."""
self.assert_accept_mimetype('text/*', expect='text/html')
def test_accept_generic_type_format(self):
"""Ensure server responds with an appropriate type, when the type and subtype are left generic."""
self.assert_accept_mimetype('*/*', expect='application/json')
def test_invalid_accept_header_returns_406(self):
"""Ensure server returns a 406 (not acceptable) response if we set the Accept header to junk."""
resp = self.client.get(reverse(views.RootResource), HTTP_ACCEPT='invalid/invalid')
self.assertNotEquals(resp['content-type'], 'invalid/invalid')
self.assertEquals(resp.status_code, 406)
def test_prefer_specific_over_generic(self): # This test is broken right now
"""More specific accept types have precedence over less specific types."""
self.assert_accept_mimetype('application/xml, */*', expect='application/xml')
self.assert_accept_mimetype('*/*, application/xml', expect='application/xml')
class AllowedMethodsTests(TestCase):
"""Basic tests to check that only allowed operations may be performed on a Resource"""
def test_reading_a_read_only_resource_is_allowed(self):
"""GET requests on a read only resource should default to a 200 (OK) response"""
resp = self.client.get(reverse(views.RootResource))
self.assertEquals(resp.status_code, 200)
def test_writing_to_read_only_resource_is_not_allowed(self):
"""PUT requests on a read only resource should default to a 405 (method not allowed) response"""
resp = self.client.put(reverse(views.RootResource), {})
self.assertEquals(resp.status_code, 405)
#
# def test_reading_write_only_not_allowed(self):
# resp = self.client.get(reverse(views.WriteOnlyResource))
# self.assertEquals(resp.status_code, 405)
#
# def test_writing_write_only_allowed(self):
# resp = self.client.put(reverse(views.WriteOnlyResource), {})
# self.assertEquals(resp.status_code, 200)
#
#
#class EncodeDecodeTests(TestCase):
# def setUp(self):
# super(self.__class__, self).setUp()
# self.input = {'a': 1, 'b': 'example'}
#
# def test_encode_form_decode_json(self):
# content = self.input
# resp = self.client.put(reverse(views.WriteOnlyResource), content)
# output = json.loads(resp.content)
# self.assertEquals(self.input, output)
#
# def test_encode_json_decode_json(self):
# content = json.dumps(self.input)
# resp = self.client.put(reverse(views.WriteOnlyResource), content, 'application/json')
# output = json.loads(resp.content)
# self.assertEquals(self.input, output)
#
# #def test_encode_xml_decode_json(self):
# # content = dict2xml(self.input)
# # resp = self.client.put(reverse(views.WriteOnlyResource), content, 'application/json', HTTP_ACCEPT='application/json')
# # output = json.loads(resp.content)
# # self.assertEquals(self.input, output)
#
# #def test_encode_form_decode_xml(self):
# # content = self.input
# # resp = self.client.put(reverse(views.WriteOnlyResource), content, HTTP_ACCEPT='application/xml')
# # output = xml2dict(resp.content)
# # self.assertEquals(self.input, output)
#
# #def test_encode_json_decode_xml(self):
# # content = json.dumps(self.input)
# # resp = self.client.put(reverse(views.WriteOnlyResource), content, 'application/json', HTTP_ACCEPT='application/xml')
# # output = xml2dict(resp.content)
# # self.assertEquals(self.input, output)
#
# #def test_encode_xml_decode_xml(self):
# # content = dict2xml(self.input)
# # resp = self.client.put(reverse(views.WriteOnlyResource), content, 'application/json', HTTP_ACCEPT='application/xml')
# # output = xml2dict(resp.content)
# # self.assertEquals(self.input, output)
#
#class ModelTests(TestCase):
# def test_create_container(self):
# content = json.dumps({'name': 'example'})
# resp = self.client.post(reverse(views.ContainerFactory), content, 'application/json')
# output = json.loads(resp.content)
# self.assertEquals(resp.status_code, 201)
# self.assertEquals(output['name'], 'example')
# self.assertEquals(set(output.keys()), set(('absolute_uri', 'name', 'key')))
#
#class CreatedModelTests(TestCase):
# def setUp(self):
# content = json.dumps({'name': 'example'})
# resp = self.client.post(reverse(views.ContainerFactory), content, 'application/json', HTTP_ACCEPT='application/json')
# self.container = json.loads(resp.content)
#
# def test_read_container(self):
# resp = self.client.get(self.container["absolute_uri"])
# self.assertEquals(resp.status_code, 200)
# container = json.loads(resp.content)
# self.assertEquals(container, self.container)
#
# def test_delete_container(self):
# resp = self.client.delete(self.container["absolute_uri"])
# self.assertEquals(resp.status_code, 204)
# self.assertEquals(resp.content, '')
#
# def test_update_container(self):
# self.container['name'] = 'new'
# content = json.dumps(self.container)
# resp = self.client.put(self.container["absolute_uri"], content, 'application/json')
# self.assertEquals(resp.status_code, 200)
# container = json.loads(resp.content)
# self.assertEquals(container, self.container)

11
examples/blogpost/urls.py Normal file
View File

@ -0,0 +1,11 @@
from django.conf.urls.defaults import patterns
urlpatterns = patterns('blogpost.views',
(r'^$', 'RootResource'),
(r'^blog-posts/$', 'BlogPostList'),
(r'^blog-post/$', 'BlogPostCreator'),
(r'^blog-post/(?P<key>[^/]+)/$', 'BlogPostInstance'),
(r'^blog-post/(?P<blogpost_id>[^/]+)/comments/$', 'CommentList'),
(r'^blog-post/(?P<blogpost_id>[^/]+)/comment/$', 'CommentCreator'),
(r'^blog-post/(?P<blogpost>[^/]+)/comments/(?P<id>[^/]+)/$', 'CommentInstance'),
)

View File

@ -0,0 +1,63 @@
from flywheel.response import Response, status
from flywheel.resource import Resource
from flywheel.modelresource import ModelResource, QueryModelResource
from blogpost.models import BlogPost, Comment
##### Root Resource #####
class RootResource(Resource):
"""This is the top level resource for the API.
All the sub-resources are discoverable from here."""
allowed_methods = ('GET',)
def get(self, request, *args, **kwargs):
return Response(status.HTTP_200_OK,
{'blog-posts': self.reverse(BlogPostList),
'blog-post': self.reverse(BlogPostCreator)})
##### Blog Post Resources #####
BLOG_POST_FIELDS = ('created', 'title', 'slug', 'content', 'absolute_url', 'comment_url', 'comments_url')
class BlogPostList(QueryModelResource):
"""A resource which lists all existing blog posts."""
allowed_methods = ('GET', )
model = BlogPost
fields = BLOG_POST_FIELDS
class BlogPostCreator(ModelResource):
"""A resource with which blog posts may be created."""
allowed_methods = ('POST',)
model = BlogPost
fields = BLOG_POST_FIELDS
class BlogPostInstance(ModelResource):
"""A resource which represents a single blog post."""
allowed_methods = ('GET', 'PUT', 'DELETE')
model = BlogPost
fields = BLOG_POST_FIELDS
##### Comment Resources #####
COMMENT_FIELDS = ('username', 'comment', 'created', 'rating', 'absolute_url', 'blogpost_url')
class CommentList(QueryModelResource):
"""A resource which lists all existing comments for a given blog post."""
allowed_methods = ('GET', )
model = Comment
fields = COMMENT_FIELDS
class CommentCreator(ModelResource):
"""A resource with which blog comments may be created for a given blog post."""
allowed_methods = ('POST',)
model = Comment
fields = COMMENT_FIELDS
class CommentInstance(ModelResource):
"""A resource which represents a single comment."""
allowed_methods = ('GET', 'PUT', 'DELETE')
model = Comment
fields = COMMENT_FIELDS

View File

@ -0,0 +1,20 @@
[
{
"pk": 1,
"model": "auth.user",
"fields": {
"username": "admin",
"first_name": "",
"last_name": "",
"is_active": true,
"is_superuser": true,
"is_staff": true,
"last_login": "2010-01-01 00:00:00",
"groups": [],
"user_permissions": [],
"password": "sha1$6cbce$e4e808893d586a3301ac3c14da6c84855999f1d8",
"email": "test@example.com",
"date_joined": "2010-01-01 00:00:00"
}
}
]

11
examples/manage.py Executable file
View File

@ -0,0 +1,11 @@
#!/usr/bin/env python
from django.core.management import execute_manager
try:
import settings # Assumed to be in the same directory.
except ImportError:
import sys
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__)
sys.exit(1)
if __name__ == "__main__":
execute_manager(settings)

View File

View File

@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

View File

@ -0,0 +1,23 @@
"""
This file demonstrates two different styles of tests (one doctest and one
unittest). These will both pass when you run "manage.py test".
Replace these with more appropriate tests for your application.
"""
from django.test import TestCase
class SimpleTest(TestCase):
def test_basic_addition(self):
"""
Tests that 1 + 1 always equals 2.
"""
self.failUnlessEqual(1 + 1, 2)
__test__ = {"doctest": """
Another way to test that 1 + 1 is equal to 2.
>>> 1 + 1 == 2
True
"""}

View File

@ -0,0 +1,6 @@
from django.conf.urls.defaults import patterns
urlpatterns = patterns('objectstore.views',
(r'^$', 'ObjectStoreRoot'),
(r'^(?P<key>[A-Za-z0-9_-]{1,64})/$', 'StoredObject'),
)

View File

@ -0,0 +1,54 @@
from django.conf import settings
from flywheel.resource import Resource
from flywheel.response import Response, status
import pickle
import os
import uuid
OBJECT_STORE_DIR = os.path.join(settings.MEDIA_ROOT, 'objectstore')
class ObjectStoreRoot(Resource):
"""Root of the Object Store API.
Allows the client to get a complete list of all the stored objects, or to create a new stored object."""
allowed_methods = ('GET', 'POST')
def get(self, request):
"""Return a list of all the stored object URLs."""
keys = sorted(os.listdir(OBJECT_STORE_DIR))
return [self.reverse(StoredObject, key=key) for key in keys]
def post(self, request, content):
"""Create a new stored object, with a unique key."""
key = str(uuid.uuid1())
pathname = os.path.join(OBJECT_STORE_DIR, key)
pickle.dump(content, open(pathname, 'wb'))
return Response(status.HTTP_201_CREATED, content, {'Location': self.reverse(StoredObject, key=key)})
class StoredObject(Resource):
"""Represents a stored object.
The object may be any picklable content."""
allowed_methods = ('GET', 'PUT', 'DELETE')
def get(self, request, key):
"""Return a stored object, by unpickling the contents of a locally stored file."""
pathname = os.path.join(OBJECT_STORE_DIR, key)
if not os.path.exists(pathname):
return Response(status.HTTP_404_NOT_FOUND)
return pickle.load(open(pathname, 'rb'))
def put(self, request, content, key):
"""Update/create a stored object, by pickling the request content to a locally stored file."""
pathname = os.path.join(OBJECT_STORE_DIR, key)
pickle.dump(content, open(pathname, 'wb'))
return content
def delete(self, request, key):
"""Delete a stored object, by removing it's pickled file."""
pathname = os.path.join(OBJECT_STORE_DIR, key)
if not os.path.exists(pathname):
return Response(status.HTTP_404_NOT_FOUND)
os.remove(pathname)

96
examples/settings.py Normal file
View File

@ -0,0 +1,96 @@
# Django settings for src project.
DEBUG = True
TEMPLATE_DEBUG = DEBUG
ADMINS = (
# ('Your Name', 'your_email@domain.com'),
)
MANAGERS = ADMINS
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3', # Add 'postgresql_psycopg2', 'postgresql', 'mysql', 'sqlite3' or 'oracle'.
'NAME': 'sqlite3.db', # Or path to database file if using sqlite3.
'USER': '', # Not used with sqlite3.
'PASSWORD': '', # Not used with sqlite3.
'HOST': '', # Set to empty string for localhost. Not used with sqlite3.
'PORT': '', # Set to empty string for default. Not used with sqlite3.
}
}
# Local time zone for this installation. Choices can be found here:
# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
# although not all choices may be available on all operating systems.
# On Unix systems, a value of None will cause Django to use the same
# timezone as the operating system.
# If running in a Windows environment this must be set to the same as your
# system time zone.
TIME_ZONE = 'Europe/London'
# Language code for this installation. All choices can be found here:
# http://www.i18nguy.com/unicode/language-identifiers.html
LANGUAGE_CODE = 'en-uk'
SITE_ID = 1
# If you set this to False, Django will make some optimizations so as not
# to load the internationalization machinery.
USE_I18N = True
# If you set this to False, Django will not format dates, numbers and
# calendars according to the current locale
USE_L10N = True
# Absolute filesystem path to the directory that will hold user-uploaded files.
# Example: "/home/media/media.lawrence.com/"
MEDIA_ROOT = '/Users/tomchristie/'
# URL that handles the media served from MEDIA_ROOT. Make sure to use a
# trailing slash if there is a path component (optional in other cases).
# Examples: "http://media.lawrence.com", "http://example.com/media/"
MEDIA_URL = ''
# URL prefix for admin media -- CSS, JavaScript and images. Make sure to use a
# trailing slash.
# Examples: "http://foo.com/media/", "/media/".
ADMIN_MEDIA_PREFIX = '/media/'
# Make this unique, and don't share it with anybody.
SECRET_KEY = 't&9mru2_k$t8e2-9uq-wu2a1)9v*us&j3i#lsqkt(lbx*vh1cu'
# List of callables that know how to import templates from various sources.
TEMPLATE_LOADERS = (
'django.template.loaders.filesystem.Loader',
'django.template.loaders.app_directories.Loader',
# 'django.template.loaders.eggs.Loader',
)
MIDDLEWARE_CLASSES = (
'django.middleware.common.CommonMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
)
ROOT_URLCONF = 'urls'
TEMPLATE_DIRS = (
# Put strings here, like "/home/html/django_templates" or "C:/www/django/templates".
# Always use forward slashes, even on Windows.
# Don't forget to use absolute paths, not relative paths.
)
INSTALLED_APPS = (
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.sites',
'django.contrib.messages',
'django.contrib.admin',
'flywheel',
'blogpost',
'objectstore'
)

11
examples/urls.py Normal file
View File

@ -0,0 +1,11 @@
from django.conf.urls.defaults import patterns, include
from django.contrib import admin
admin.autodiscover()
urlpatterns = patterns('',
(r'^blog-post-example/', include('blogpost.urls')),
(r'^object-store-example/', include('objectstore.urls')),
(r'^admin/doc/', include('django.contrib.admindocs.urls')),
(r'^admin/', include(admin.site.urls)),
)

0
flywheel/__init__.py Normal file
View File

118
flywheel/emitters.py Normal file
View File

@ -0,0 +1,118 @@
from django.template import RequestContext, loader
from flywheel.response import NoContent
from utils import dict2xml
try:
import json
except ImportError:
import simplejson as json
class BaseEmitter(object):
media_type = None
def __init__(self, resource):
self.resource = resource
def emit(self, output=NoContent, verbose=False):
raise Exception('emit() function on a subclass of BaseEmitter must be implemented')
from django import forms
class JSONForm(forms.Form):
_contenttype = forms.CharField(max_length=256, initial='application/json', label='Content Type')
_content = forms.CharField(label='Content', widget=forms.Textarea)
class DocumentingTemplateEmitter(BaseEmitter):
"""Emitter used to self-document the API"""
template = None
def emit(self, output=NoContent):
resource = self.resource
# Find the first valid emitter and emit the content. (Don't another documenting emitter.)
emitters = [emitter for emitter in resource.emitters if not isinstance(emitter, DocumentingTemplateEmitter)]
if not emitters:
content = 'No emitters were found'
else:
content = emitters[0](resource).emit(output, verbose=True)
# Get the form instance if we have one bound to the input
form_instance = resource.form_instance
# Otherwise if this isn't an error response
# then attempt to get a form bound to the response object
if not form_instance and not resource.response.is_error and resource.response.has_content_body:
try:
form_instance = resource.get_form(resource.response.raw_content)
except:
pass
# If we still don't have a form instance then try to get an unbound form
if not form_instance:
try:
form_instance = self.resource.get_form()
except:
pass
if not form_instance:
form_instance = JSONForm()
template = loader.get_template(self.template)
context = RequestContext(self.resource.request, {
'content': content,
'resource': self.resource,
'request': self.resource.request,
'response': self.resource.response,
'form': form_instance
})
ret = template.render(context)
# Munge DELETE Response code to allow us to return content
# (Do this *after* we've rendered the template so that we include the normal deletion response code in the output)
if self.resource.response.status == 204:
self.resource.response.status = 200
return ret
class JSONEmitter(BaseEmitter):
media_type = 'application/json'
def emit(self, output=NoContent, verbose=False):
if output is NoContent:
return ''
if verbose:
return json.dumps(output, indent=4, sort_keys=True)
return json.dumps(output)
class XMLEmitter(BaseEmitter):
media_type = 'application/xml'
def emit(self, output=NoContent, verbose=False):
if output is NoContent:
return ''
return dict2xml(output)
class DocumentingHTMLEmitter(DocumentingTemplateEmitter):
media_type = 'text/html'
uses_forms = True
template = 'emitter.html'
class DocumentingXHTMLEmitter(DocumentingTemplateEmitter):
media_type = 'application/xhtml+xml'
uses_forms = True
template = 'emitter.html'
class DocumentingPlainTextEmitter(DocumentingTemplateEmitter):
media_type = 'text/plain'
template = 'emitter.txt'

393
flywheel/modelresource.py Normal file
View File

@ -0,0 +1,393 @@
"""TODO: docs
"""
from django.forms import ModelForm
from django.db.models.query import QuerySet
from django.db.models import Model
from flywheel.response import status, Response, ResponseException
from flywheel.resource import Resource
import decimal
import inspect
import re
class ModelResource(Resource):
"""A specialized type of Resource, for resources that map directly to a Django Model.
Useful things this provides:
0. Default input validation based on ModelForms.
1. Nice serialization of returned Models and QuerySets.
2. A default set of create/read/update/delete operations."""
# The model attribute refers to the Django Model which this Resource maps to.
# (The Model's class, rather than an instance of the Model)
model = None
# By default the set of returned fields will be the set of:
#
# 0. All the fields on the model, excluding 'id'.
# 1. All the properties on the model.
# 2. The absolute_url of the model, if a get_absolute_url method exists for the model.
#
# If you wish to override this behaviour,
# you should explicitly set the fields attribute on your class.
fields = None
# By default the form used with be a ModelForm for self.model
# If you wish to override this behaviour or provide a sub-classed ModelForm
# you should explicitly set the form attribute on your class.
form = None
# By default the set of input fields will be the same as the set of output fields
# If you wish to override this behaviour you should explicitly set the
# form_fields attribute on your class.
form_fields = None
def get_form(self, content=None):
"""Return a form that may be used in validation and/or rendering an html emitter"""
if self.form:
return super(self.__class__, self).get_form(content)
elif self.model:
class NewModelForm(ModelForm):
class Meta:
model = self.model
fields = self.form_fields if self.form_fields else None
if content and isinstance(content, Model):
return NewModelForm(instance=content)
elif content:
return NewModelForm(content)
return NewModelForm()
return None
def cleanup_request(self, data, form_instance):
"""Override cleanup_request to drop read-only fields from the input prior to validation.
This ensures that we don't error out with 'non-existent field' when these fields are supplied,
and allows for a pragmatic approach to resources which include read-only elements.
I would actually like to be strict and verify the value of correctness of the values in these fields,
although that gets tricky as it involves validating at the point that we get the model instance.
See here for another example of this approach:
http://fedoraproject.org/wiki/Cloud_APIs_REST_Style_Guide
https://www.redhat.com/archives/rest-practices/2010-April/thread.html#00041"""
read_only_fields = set(self.fields) - set(self.form_instance.fields)
input_fields = set(data.keys())
clean_data = {}
for key in input_fields - read_only_fields:
clean_data[key] = data[key]
return super(ModelResource, self).cleanup_request(clean_data, form_instance)
def cleanup_response(self, data):
"""A munging of Piston's pre-serialization. Returns a dict"""
def _any(thing, fields=()):
"""
Dispatch, all types are routed through here.
"""
ret = None
if isinstance(thing, QuerySet):
ret = _qs(thing, fields=fields)
elif isinstance(thing, (tuple, list)):
ret = _list(thing)
elif isinstance(thing, dict):
ret = _dict(thing)
elif isinstance(thing, int):
ret = thing
elif isinstance(thing, bool):
ret = thing
elif isinstance(thing, type(None)):
ret = thing
elif isinstance(thing, decimal.Decimal):
ret = str(thing)
elif isinstance(thing, Model):
ret = _model(thing, fields=fields)
#elif isinstance(thing, HttpResponse): TRC
# raise HttpStatusCode(thing)
elif inspect.isfunction(thing):
if not inspect.getargspec(thing)[0]:
ret = _any(thing())
elif hasattr(thing, '__emittable__'):
f = thing.__emittable__
if inspect.ismethod(f) and len(inspect.getargspec(f)[0]) == 1:
ret = _any(f())
else:
ret = str(thing) # TRC TODO: Change this back!
return ret
def _fk(data, field):
"""
Foreign keys.
"""
return _any(getattr(data, field.name))
def _related(data, fields=()):
"""
Foreign keys.
"""
return [ _model(m, fields) for m in data.iterator() ]
def _m2m(data, field, fields=()):
"""
Many to many (re-route to `_model`.)
"""
return [ _model(m, fields) for m in getattr(data, field.name).iterator() ]
def _method_fields(data, fields):
if not data:
return { }
has = dir(data)
ret = dict()
for field in fields:
if field in has:
ret[field] = getattr(data, field)
return ret
def _model(data, fields=()):
"""
Models. Will respect the `fields` and/or
`exclude` on the handler (see `typemapper`.)
"""
ret = { }
#handler = self.in_typemapper(type(data), self.anonymous) # TRC
handler = None # TRC
get_absolute_url = False
if handler or fields:
v = lambda f: getattr(data, f.attname)
if not fields:
"""
Fields was not specified, try to find teh correct
version in the typemapper we were sent.
"""
mapped = self.in_typemapper(type(data), self.anonymous)
get_fields = set(mapped.fields)
exclude_fields = set(mapped.exclude).difference(get_fields)
if not get_fields:
get_fields = set([ f.attname.replace("_id", "", 1)
for f in data._meta.fields ])
# sets can be negated.
for exclude in exclude_fields:
if isinstance(exclude, basestring):
get_fields.discard(exclude)
elif isinstance(exclude, re._pattern_type):
for field in get_fields.copy():
if exclude.match(field):
get_fields.discard(field)
get_absolute_url = True
else:
get_fields = set(fields)
if 'absolute_url' in get_fields: # MOVED (TRC)
get_absolute_url = True
met_fields = _method_fields(handler, get_fields) # TRC
for f in data._meta.local_fields:
if f.serialize and not any([ p in met_fields for p in [ f.attname, f.name ]]):
if not f.rel:
if f.attname in get_fields:
ret[f.attname] = _any(v(f))
get_fields.remove(f.attname)
else:
if f.attname[:-3] in get_fields:
ret[f.name] = _fk(data, f)
get_fields.remove(f.name)
for mf in data._meta.many_to_many:
if mf.serialize and mf.attname not in met_fields:
if mf.attname in get_fields:
ret[mf.name] = _m2m(data, mf)
get_fields.remove(mf.name)
# try to get the remainder of fields
for maybe_field in get_fields:
if isinstance(maybe_field, (list, tuple)):
model, fields = maybe_field
inst = getattr(data, model, None)
if inst:
if hasattr(inst, 'all'):
ret[model] = _related(inst, fields)
elif callable(inst):
if len(inspect.getargspec(inst)[0]) == 1:
ret[model] = _any(inst(), fields)
else:
ret[model] = _model(inst, fields)
elif maybe_field in met_fields:
# Overriding normal field which has a "resource method"
# so you can alter the contents of certain fields without
# using different names.
ret[maybe_field] = _any(met_fields[maybe_field](data))
else:
maybe = getattr(data, maybe_field, None)
if maybe:
if callable(maybe):
if len(inspect.getargspec(maybe)[0]) == 1:
ret[maybe_field] = _any(maybe())
else:
ret[maybe_field] = _any(maybe)
else:
pass # TRC
#handler_f = getattr(handler or self.handler, maybe_field, None)
#
#if handler_f:
# ret[maybe_field] = _any(handler_f(data))
else:
# Add absolute_url if it exists
get_absolute_url = True
# Add all the fields
for f in data._meta.fields:
if f.attname != 'id':
ret[f.attname] = _any(getattr(data, f.attname))
# Add all the propertiess
klass = data.__class__
for attr in dir(klass):
if not attr.startswith('_') and not attr in ('pk','id') and isinstance(getattr(klass, attr, None), property):
#if attr.endswith('_url') or attr.endswith('_uri'):
# ret[attr] = self.make_absolute(_any(getattr(data, attr)))
#else:
ret[attr] = _any(getattr(data, attr))
#fields = dir(data.__class__) + ret.keys()
#add_ons = [k for k in dir(data) if k not in fields and not k.startswith('_')]
#print add_ons
###print dir(data.__class__)
#from django.db.models import Model
#model_fields = dir(Model)
#for attr in dir(data):
## #if attr.startswith('_'):
## # continue
# if (attr in fields) and not (attr in model_fields) and not attr.startswith('_'):
# print attr, type(getattr(data, attr, None)), attr in fields, attr in model_fields
#for k in add_ons:
# ret[k] = _any(getattr(data, k))
# TRC
# resouce uri
#if self.in_typemapper(type(data), self.anonymous):
# handler = self.in_typemapper(type(data), self.anonymous)
# if hasattr(handler, 'resource_uri'):
# url_id, fields = handler.resource_uri()
# ret['resource_uri'] = permalink( lambda: (url_id,
# (getattr(data, f) for f in fields) ) )()
# TRC
#if hasattr(data, 'get_api_url') and 'resource_uri' not in ret:
# try: ret['resource_uri'] = data.get_api_url()
# except: pass
# absolute uri
if hasattr(data, 'get_absolute_url') and get_absolute_url:
try: ret['absolute_url'] = data.get_absolute_url()
except: pass
for key, val in ret.items():
if key.endswith('_url') or key.endswith('_uri'):
ret[key] = self.add_domain(val)
return ret
def _qs(data, fields=()):
"""
Querysets.
"""
return [ _any(v, fields) for v in data ]
def _list(data):
"""
Lists.
"""
return [ _any(v) for v in data ]
def _dict(data):
"""
Dictionaries.
"""
return dict([ (k, _any(v)) for k, v in data.iteritems() ])
# Kickstart the seralizin'.
return _any(data, self.fields)
def post(self, request, content, *args, **kwargs):
# TODO: test creation on a non-existing resource url
all_kw_args = dict(content.items() + kwargs.items())
instance = self.model(**all_kw_args)
instance.save()
headers = {}
if hasattr(instance, 'get_absolute_url'):
headers['Location'] = self.add_domain(instance.get_absolute_url())
return Response(status.HTTP_201_CREATED, instance, headers)
def get(self, request, *args, **kwargs):
try:
instance = self.model.objects.get(**kwargs)
except self.model.DoesNotExist:
raise ResponseException(status.HTTP_404_NOT_FOUND)
return instance
def put(self, request, content, *args, **kwargs):
# TODO: update on the url of a non-existing resource url doesn't work correctly at the moment - will end up with a new url
try:
instance = self.model.objects.get(**kwargs)
for (key, val) in content.items():
setattr(instance, key, val)
except self.model.DoesNotExist:
instance = self.model(**content)
instance.save()
instance.save()
return instance
def delete(self, request, *args, **kwargs):
try:
instance = self.model.objects.get(**kwargs)
except self.model.DoesNotExist:
raise ResponseException(status.HTTP_404_NOT_FOUND, None, {})
instance.delete()
return
class QueryModelResource(ModelResource):
allowed_methods = ('read',)
queryset = None
def get_form(self, data=None):
return None
def get(self, request, *args, **kwargs):
queryset = self.queryset if self.queryset else self.model.objects.all()
return queryset

80
flywheel/parsers.py Normal file
View File

@ -0,0 +1,80 @@
from flywheel.response import status, ResponseException
try:
import json
except ImportError:
import simplejson as json
# TODO: Make all parsers only list a single media_type, rather than a list
class BaseParser(object):
media_types = ()
def __init__(self, resource):
self.resource = resource
def parse(self, input):
return {}
class JSONParser(BaseParser):
media_types = ('application/xml',)
def parse(self, input):
try:
return json.loads(input)
except ValueError, exc:
raise ResponseException(status.HTTP_400_BAD_REQUEST, {'detail': 'JSON parse error - %s' % str(exc)})
class XMLParser(BaseParser):
media_types = ('application/xml',)
class FormParser(BaseParser):
"""The default parser for form data.
Return a dict containing a single value for each non-reserved parameter.
"""
media_types = ('application/x-www-form-urlencoded',)
def parse(self, input):
# The FormParser doesn't parse the input as other parsers would, since Django's already done the
# form parsing for us. We build the content object from the request directly.
request = self.resource.request
if request.method == 'PUT':
# Fix from piston to force Django to give PUT requests the same
# form processing that POST requests get...
#
# Bug fix: if _load_post_and_files has already been called, for
# example by middleware accessing request.POST, the below code to
# pretend the request is a POST instead of a PUT will be too late
# to make a difference. Also calling _load_post_and_files will result
# in the following exception:
# AttributeError: You cannot set the upload handlers after the upload has been processed.
# The fix is to check for the presence of the _post field which is set
# the first time _load_post_and_files is called (both by wsgi.py and
# modpython.py). If it's set, the request has to be 'reset' to redo
# the query value parsing in POST mode.
if hasattr(request, '_post'):
del request._post
del request._files
try:
request.method = "POST"
request._load_post_and_files()
request.method = "PUT"
except AttributeError:
request.META['REQUEST_METHOD'] = 'POST'
request._load_post_and_files()
request.META['REQUEST_METHOD'] = 'PUT'
# Strip any parameters that we are treating as reserved
data = {}
for (key, val) in request.POST.items():
if key not in self.resource.RESERVED_FORM_PARAMS:
data[key] = val
return data

436
flywheel/resource.py Normal file
View File

@ -0,0 +1,436 @@
from django.contrib.sites.models import Site
from django.core.urlresolvers import reverse
from django.http import HttpResponse
from flywheel import emitters, parsers
from flywheel.response import status, Response, ResponseException
from decimal import Decimal
import re
from itertools import chain
# TODO: Authentication
# TODO: Display user login in top panel: http://stackoverflow.com/questions/806835/django-redirect-to-previous-page-after-login
# TODO: Figure how out references and named urls need to work nicely
# TODO: POST on existing 404 URL, PUT on existing 404 URL
# TODO: Remove is_error throughout
#
# NEXT: Exceptions on func() -> 500, tracebacks emitted if settings.DEBUG
# NEXT: Generic content form
# NEXT: Remove self.blah munging (Add a ResponseContext object?)
# NEXT: Caching cleverness
# NEXT: Test non-existent fields on ModelResources
#
# FUTURE: Erroring on read-only fields
# Documentation, Release
__all__ = ['Resource']
class Resource(object):
"""Handles incoming requests and maps them to REST operations,
performing authentication, input deserialization, input validation, output serialization."""
# List of RESTful operations which may be performed on this resource.
allowed_methods = ('GET',)
anon_allowed_methods = ()
# List of emitters the resource can serialize the response with, ordered by preference
emitters = ( emitters.JSONEmitter,
emitters.DocumentingHTMLEmitter,
emitters.DocumentingXHTMLEmitter,
emitters.DocumentingPlainTextEmitter,
emitters.XMLEmitter )
# List of content-types the resource can read from
parsers = ( parsers.JSONParser,
parsers.XMLParser,
parsers.FormParser )
# Optional form for input validation and presentation of HTML formatted responses.
form = None
# Map standard HTTP methods to function calls
callmap = { 'GET': 'get', 'POST': 'post',
'PUT': 'put', 'DELETE': 'delete' }
# Some reserved parameters to allow us to use standard HTML forms with our resource
# Override any/all of these with None to disable them, or override them with another value to rename them.
ACCEPT_QUERY_PARAM = '_accept' # Allow override of Accept header in URL query params
METHOD_PARAM = '_method' # Allow POST overloading in form params
CONTENTTYPE_PARAM = '_contenttype' # Allow override of Content-Type header in form params (allows sending arbitrary content with standard forms)
CONTENT_PARAM = '_content' # Allow override of body content in form params (allows sending arbitrary content with standard forms)
CSRF_PARAM = 'csrfmiddlewaretoken' # Django's CSRF token used in form params
def __new__(cls, request, *args, **kwargs):
"""Make the class callable so it can be used as a Django view."""
self = object.__new__(cls)
self.__init__(request)
try:
return self._handle_request(request, *args, **kwargs)
except:
import traceback
traceback.print_exc()
raise
def __init__(self, request):
""""""
# Setup the resource context
self.request = request
self.auth_context = None
self.response = None
self.form_instance = None
# These sets are determined now so that overridding classes can modify the various parameter names,
# or set them to None to disable them.
self.RESERVED_FORM_PARAMS = set((self.METHOD_PARAM, self.CONTENTTYPE_PARAM, self.CONTENT_PARAM, self.CSRF_PARAM))
self.RESERVED_QUERY_PARAMS = set((self.ACCEPT_QUERY_PARAM))
self.RESERVED_FORM_PARAMS.discard(None)
self.RESERVED_QUERY_PARAMS.discard(None)
@property
def name(self):
"""Provide a name for the resource.
By default this is the class name, with 'CamelCaseNames' converted to 'Camel Case Names'."""
class_name = self.__class__.__name__
return re.sub('(((?<=[a-z])[A-Z])|([A-Z](?![A-Z]|$)))', ' \\1', class_name).strip()
@property
def description(self):
"""Provide a description for the resource.
By default this is the class's docstring with leading line spaces stripped."""
return re.sub(re.compile('^ +', re.MULTILINE), '', self.__doc__)
@property
def emitted_media_types(self):
"""Return an list of all the media types that this resource can emit."""
return [emitter.media_type for emitter in self.emitters]
@property
def default_emitter(self):
"""Return the resource's most prefered emitter.
(This emitter is used if the client does not send and Accept: header, or sends Accept: */*)"""
return self.emitters[0]
# TODO:
#def parsed_media_types(self):
# """Return an list of all the media types that this resource can emit."""
# return [parser.media_type for parser in self.parsers]
#def deafult_parser(self):
# return self.parsers[0]
def reverse(self, view, *args, **kwargs):
"""Return a fully qualified URI for a given view or resource.
Add the domain using the Sites framework if possible, otherwise fallback to using the current request."""
return self.add_domain(reverse(view, args=args, kwargs=kwargs))
def authenticate(self, request):
"""TODO"""
return None
# user = ...
# if DEBUG and request is from localhost
# if anon_user and not anon_allowed_methods raise PermissionDenied
# return auth_context
def get(self, request, *args, **kwargs):
"""Must be subclassed to be implemented."""
self.not_implemented('GET')
def post(self, request, content, *args, **kwargs):
"""Must be subclassed to be implemented."""
self.not_implemented('POST')
def put(self, request, content, *args, **kwargs):
"""Must be subclassed to be implemented."""
self.not_implemented('PUT')
def delete(self, request, *args, **kwargs):
"""Must be subclassed to be implemented."""
self.not_implemented('DELETE')
def not_implemented(self, operation):
"""Return an HTTP 500 server error if an operation is called which has been allowed by
allowed_methods, but which has not been implemented."""
raise ResponseException(status.HTTP_500_INTERNAL_SERVER_ERROR,
{'detail': '%s operation on this resource has not been implemented' % (operation, )})
def add_domain(self, path):
"""Given a path, return an fully qualified URI.
Use the Sites framework if possible, otherwise fallback to using the domain from the current request."""
# Note that out-of-the-box the Sites framework uses the reserved domain 'example.com'
# See RFC 2606 - http://www.faqs.org/rfcs/rfc2606.html
try:
site = Site.objects.get_current()
if site.domain and site.domain != 'example.com':
return 'http://%s%s' % (site.domain, path)
except:
pass
return self.request.build_absolute_uri(path)
def determine_method(self, request):
"""Determine the HTTP method that this request should be treated as.
Allows PUT and DELETE tunneling via the _method parameter if METHOD_PARAM is set."""
method = request.method.upper()
if method == 'POST' and self.METHOD_PARAM and request.POST.has_key(self.METHOD_PARAM):
method = request.POST[self.METHOD_PARAM].upper()
return method
def check_method_allowed(self, method):
"""Ensure the request method is acceptable for this resource."""
if not method in self.callmap.keys():
raise ResponseException(status.HTTP_501_NOT_IMPLEMENTED,
{'detail': 'Unknown or unsupported method \'%s\'' % method})
if not method in self.allowed_methods:
raise ResponseException(status.HTTP_405_METHOD_NOT_ALLOWED,
{'detail': 'Method \'%s\' not allowed on this resource.' % method})
def get_form(self, data=None):
"""Optionally return a Django Form instance, which may be used for validation
and/or rendered by an HTML/XHTML emitter.
If data is not None the form will be bound to data. is_response indicates if data should be
treated as the input data (bind to client input) or the response data (bind to an existing object)."""
if self.form:
if data:
return self.form(data)
else:
return self.form()
return None
def cleanup_request(self, data, form_instance):
"""Perform any resource-specific data deserialization and/or validation
after the initial HTTP content-type deserialization has taken place.
Returns a tuple containing the cleaned up data, and optionally a form bound to that data.
By default this uses form validation to filter the basic input into the required types."""
if form_instance is None:
return data
# Default form validation does not check for additional invalid fields
non_existent_fields = []
for key in set(data.keys()) - set(form_instance.fields.keys()):
non_existent_fields.append(key)
if not form_instance.is_valid() or non_existent_fields:
if not form_instance.errors and not non_existent_fields:
# If no data was supplied the errors property will be None
details = 'No content was supplied'
else:
# Add standard field errors
details = dict((key, map(unicode, val)) for (key, val) in form_instance.errors.iteritems())
# Add any non-field errors
if form_instance.non_field_errors():
details['errors'] = form_instance.non_field_errors()
# Add any non-existent field errors
for key in non_existent_fields:
details[key] = ['This field does not exist']
# Bail. Note that we will still serialize this response with the appropriate content type
raise ResponseException(status.HTTP_400_BAD_REQUEST, {'detail': details})
return form_instance.cleaned_data
def cleanup_response(self, data):
"""Perform any resource-specific data filtering prior to the standard HTTP
content-type serialization.
Eg filter complex objects that cannot be serialized by json/xml/etc into basic objects that can."""
return data
def determine_parser(self, request):
"""Return the appropriate parser for the input, given the client's 'Content-Type' header,
and the content types that this Resource knows how to parse."""
content_type = request.META.get('CONTENT_TYPE', 'application/x-www-form-urlencoded')
split = content_type.split(';', 1)
if len(split) > 1:
content_type = split[0]
content_type = content_type.strip()
# Create a list of list of (media_type, Parser) tuples
media_type_parser_tuples = [[(media_type, parser) for media_type in parser.media_types] for parser in self.parsers]
# Flatten the list and turn it into a media_type -> Parser dict
media_type_to_parser = dict(chain.from_iterable(media_type_parser_tuples))
try:
return media_type_to_parser[content_type]
except KeyError:
raise ResponseException(status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
{'detail': 'Unsupported media type \'%s\'' % content_type})
def determine_emitter(self, request):
"""Return the appropriate emitter for the output, given the client's 'Accept' header,
and the content types that this Resource knows how to serve.
See: RFC 2616, Section 14 - http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html"""
if self.ACCEPT_QUERY_PARAM and request.GET.get(self.ACCEPT_QUERY_PARAM, None):
# Use _accept parameter override
accept_list = [request.GET.get(self.ACCEPT_QUERY_PARAM)]
elif request.META.has_key('HTTP_ACCEPT'):
# Use standard HTTP Accept negotiation
accept_list = request.META["HTTP_ACCEPT"].split(',')
else:
# No accept header specified
return self.default_emitter
# Parse the accept header into a dict of {qvalue: set of media types}
# We ignore mietype parameters
accept_dict = {}
for token in accept_list:
components = token.split(';')
mimetype = components[0].strip()
qvalue = Decimal('1.0')
if len(components) > 1:
# Parse items that have a qvalue eg text/html;q=0.9
try:
(q, num) = components[-1].split('=')
if q == 'q':
qvalue = Decimal(num)
except:
# Skip malformed entries
continue
if accept_dict.has_key(qvalue):
accept_dict[qvalue].add(mimetype)
else:
accept_dict[qvalue] = set((mimetype,))
# Convert to a list of sets ordered by qvalue (highest first)
accept_sets = [accept_dict[qvalue] for qvalue in sorted(accept_dict.keys(), reverse=True)]
for accept_set in accept_sets:
# Return any exact match
for emitter in self.emitters:
if emitter.media_type in accept_set:
return emitter
# Return any subtype match
for emitter in self.emitters:
if emitter.media_type.split('/')[0] + '/*' in accept_set:
return emitter
# Return default
if '*/*' in accept_set:
return self.default_emitter
raise ResponseException(status.HTTP_406_NOT_ACCEPTABLE,
{'detail': 'Could not statisfy the client\'s Accept header',
'available_types': self.emitted_media_types})
def _handle_request(self, request, *args, **kwargs):
"""
Broadly this consists of the following procedure:
0. ensure the operation is permitted
1. deserialize request content into request data, using standard HTTP content types (PUT/POST only)
2. cleanup and validate request data (PUT/POST only)
3. call the core method to get the response data
4. cleanup the response data
5. serialize response data into response content, using standard HTTP content negotiation
"""
emitter = None
method = self.determine_method(request)
try:
# Before we attempt anything else determine what format to emit our response data with.
emitter = self.determine_emitter(request)
# Authenticate the request, and store any context so that the resource operations can
# do more fine grained authentication if required.
#
# Typically the context will be a user, or None if this is an anonymous request,
# but it could potentially be more complex (eg the context of a request key which
# has been signed against a particular set of permissions)
self.auth_context = self.authenticate(request)
# Ensure the requested operation is permitted on this resource
self.check_method_allowed(method)
# Get the appropriate create/read/update/delete function
func = getattr(self, self.callmap.get(method, None))
# Either generate the response data, deserializing and validating any request data
# TODO: Add support for message bodys on other HTTP methods, as it is valid.
if method in ('PUT', 'POST'):
parser = self.determine_parser(request)
data = parser(self).parse(request.raw_post_data)
self.form_instance = self.get_form(data)
data = self.cleanup_request(data, self.form_instance)
response = func(request, data, *args, **kwargs)
else:
response = func(request, *args, **kwargs)
# Allow return value to be either Response, or an object, or None
if isinstance(response, Response):
self.response = response
elif response is not None:
self.response = Response(status.HTTP_200_OK, response)
else:
self.response = Response(status.HTTP_204_NO_CONTENT)
# Pre-serialize filtering (eg filter complex objects into natively serializable types)
self.response.cleaned_content = self.cleanup_response(self.response.raw_content)
except ResponseException, exc:
self.response = exc.response
# Fall back to the default emitter if we failed to perform content negotiation
if emitter is None:
emitter = self.default_emitter
# Always add these headers
self.response.headers['Allow'] = ', '.join(self.allowed_methods)
self.response.headers['Vary'] = 'Authenticate, Allow'
# Serialize the response content
if self.response.has_content_body:
content = emitter(self).emit(output=self.response.cleaned_content)
else:
content = emitter(self).emit()
# Build the HTTP Response
# TODO: Check if emitter.mimetype is underspecified, or if a content-type header has been set
resp = HttpResponse(content, mimetype=emitter.media_type, status=self.response.status)
for (key, val) in self.response.headers.items():
resp[key] = val
return resp

126
flywheel/response.py Normal file
View File

@ -0,0 +1,126 @@
from django.core.handlers.wsgi import STATUS_CODE_TEXT
__all__ =['status', 'NoContent', 'Response', ]
class Status(object):
"""Descriptive HTTP status codes, for code readability.
See RFC 2616 - Sec 10: http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html"""
# Verbose format (I prefer this as it's more explicit)
HTTP_100_CONTINUE = 100
HTTP_101_SWITCHING_PROTOCOLS = 101
HTTP_200_OK = 200
HTTP_201_CREATED = 201
HTTP_202_ACCEPTED = 202
HTTP_203_NON_AUTHORITATIVE_INFORMATION = 203
HTTP_204_NO_CONTENT = 204
HTTP_205_RESET_CONTENT = 205
HTTP_206_PARTIAL_CONTENT = 206
HTTP_300_MULTIPLE_CHOICES = 300
HTTP_301_MOVED_PERMANENTLY = 301
HTTP_302_FOUND = 302
HTTP_303_SEE_OTHER = 303
HTTP_304_NOT_MODIFIED = 304
HTTP_305_USE_PROXY = 305
HTTP_306_RESERVED = 306
HTTP_307_TEMPORARY_REDIRECT = 307
HTTP_400_BAD_REQUEST = 400
HTTP_401_UNAUTHORIZED = 401
HTTP_402_PAYMENT_REQUIRED = 402
HTTP_403_FORBIDDEN = 403
HTTP_404_NOT_FOUND = 404
HTTP_405_METHOD_NOT_ALLOWED = 405
HTTP_406_NOT_ACCEPTABLE = 406
HTTP_407_PROXY_AUTHENTICATION_REQUIRED = 407
HTTP_408_REQUEST_TIMEOUT = 408
HTTP_409_CONFLICT = 409
HTTP_410_GONE = 410
HTTP_411_LENGTH_REQUIRED = 411
HTTP_412_PRECONDITION_FAILED = 412
HTTP_413_REQUEST_ENTITY_TOO_LARGE = 413
HTTP_414_REQUEST_URI_TOO_LONG = 414
HTTP_415_UNSUPPORTED_MEDIA_TYPE = 415
HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE = 416
HTTP_417_EXPECTATION_FAILED = 417
HTTP_500_INTERNAL_SERVER_ERROR = 500
HTTP_501_NOT_IMPLEMENTED = 501
HTTP_502_BAD_GATEWAY = 502
HTTP_503_SERVICE_UNAVAILABLE = 503
HTTP_504_GATEWAY_TIMEOUT = 504
HTTP_505_HTTP_VERSION_NOT_SUPPORTED = 505
# Short format
CONTINUE = 100
SWITCHING_PROTOCOLS = 101
OK = 200
CREATED = 201
ACCEPTED = 202
NON_AUTHORITATIVE_INFORMATION = 203
NO_CONTENT = 204
RESET_CONTENT = 205
PARTIAL_CONTENT = 206
MULTIPLE_CHOICES = 300
MOVED_PERMANENTLY = 301
FOUND = 302
SEE_OTHER = 303
NOT_MODIFIED = 304
USE_PROXY = 305
RESERVED = 306
TEMPORARY_REDIRECT = 307
BAD_REQUEST = 400
UNAUTHORIZED = 401
PAYMENT_REQUIRED = 402
FORBIDDEN = 403
NOT_FOUND = 404
METHOD_NOT_ALLOWED = 405
NOT_ACCEPTABLE = 406
PROXY_AUTHENTICATION_REQUIRED = 407
REQUEST_TIMEOUT = 408
CONFLICT = 409
GONE = 410
LENGTH_REQUIRED = 411
PRECONDITION_FAILED = 412
REQUEST_ENTITY_TOO_LARGE = 413
REQUEST_URI_TOO_LONG = 414
UNSUPPORTED_MEDIA_TYPE = 415
REQUESTED_RANGE_NOT_SATISFIABLE = 416
EXPECTATION_FAILED = 417
INTERNAL_SERVER_ERROR = 500
NOT_IMPLEMENTED = 501
BAD_GATEWAY = 502
SERVICE_UNAVAILABLE = 503
GATEWAY_TIMEOUT = 504
HTTP_VERSION_NOT_SUPPORTED = 505
# This is simply stylistic, I think 'status.HTTP_200_OK' reads nicely.
status = Status()
class NoContent(object):
"""Used to indicate no body in http response.
(We cannot just use None, as that is a valid, serializable response object.)"""
pass
class Response(object):
def __init__(self, status, content=NoContent, headers={}, is_error=False):
self.status = status
self.has_content_body = not content is NoContent
self.raw_content = content # content prior to filtering
self.cleaned_content = content # content after filtering
self.headers = headers
self.is_error = is_error
@property
def status_text(self):
"""Return reason text corrosponding to our HTTP response status code.
Provided for convienience."""
return STATUS_CODE_TEXT.get(self.status, '')
class ResponseException(BaseException):
def __init__(self, status, content=NoContent, headers={}):
self.response = Response(status, content=content, headers=headers, is_error=True)

View File

@ -0,0 +1,96 @@
{% load urlize_quoted_links %}{% load add_query_param %}<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<style>
pre {border: 1px solid black; padding: 1em; background: #ffd}
div.action {border: 1px solid black; padding: 0.5em 1em; margin-bottom: 0.5em; background: #ddf}
ul.accepttypes {float: right; list-style-type: none; margin: 0; padding: 0}
ul.accepttypes li {display: inline;}
form div {margin: 0.5em 0}
form div * {vertical-align: top}
form ul.errorlist {display: inline; margin: 0; padding: 0}
form ul.errorlist li {display: inline; color: red;}
.clearing {display: block; margin: 0; padding: 0; clear: both;}
</style>
<title>API - {{ resource.name }}</title>
</head>
<body>
<h1>{{ resource.name }}</h1>
<p>{{ resource.description|linebreaksbr }}</p>
<pre><b>{{ response.status }} {{ response.status_text }}</b>{% autoescape off %}
{% for key, val in response.headers.items %}<b>{{ key }}:</b> {{ val|urlize_quoted_links }}
{% endfor %}
{{ content|urlize_quoted_links }} </pre>{% endautoescape %}
{% if 'GET' in resource.allowed_methods %}
<div class='action'>
<a href='{{ request.path }}'>GET</a>
<ul class="accepttypes">
{% for media_type in resource.emitted_media_types %}
{% with resource.ACCEPT_QUERY_PARAM|add:"="|add:media_type as param %}
<li>[<a href='{{ request.path|add_query_param:param }}'>{{ media_type }}</a>]</li>
{% endwith %}
{% endfor %}
</ul>
<div class="clearing"></div>
</div>
{% endif %}
{% comment %} *** Only display the POST/PUT/DELETE forms if we have a bound form, and if method ***
*** tunneling via POST forms is enabled. ***
*** (We could display only the POST form if method tunneling is disabled, but I think ***
*** the user experience would be confusing, so we simply turn all forms off. *** {% endcomment %}
{% if resource.METHOD_PARAM and form %}
{% if 'POST' in resource.allowed_methods %}
<div class='action'>
<form action="{{ request.path }}" method="post">
{% csrf_token %}
{% for field in form %}
<div>
{{ field.label_tag }}:
{{ field }}
{{ field.help_text }}
{{ field.errors }}
</div>
{% endfor %}
<div class="clearing"></div>
<input type="submit" value="POST" />
</form>
</div>
{% endif %}
{% if 'PUT' in resource.allowed_methods %}
<div class='action'>
<form action="{{ request.path }}" method="post">
<input type="hidden" name="{{ resource.METHOD_PARAM }}" value="PUT" />
{% csrf_token %}
{% for field in form %}
<div>
{{ field.label_tag }}:
{{ field }}
{{ field.help_text }}
{{ field.errors }}
</div>
{% endfor %}
<div class="clearing"></div>
<input type="submit" value="PUT" />
</form>
</div>
{% endif %}
{% if 'DELETE' in resource.allowed_methods %}
<div class='action'>
<form action="{{ request.path }}" method="post">
{% csrf_token %}
<input type="hidden" name="{{ resource.METHOD_PARAM }}" value="DELETE" />
<input type="submit" value="DELETE" />
</form>
</div>
{% endif %}
{% endif %}
</body>
</html>

View File

@ -0,0 +1,8 @@
{{ resource.name }}
{{ resource.description }}
{% autoescape off %}HTTP/1.0 {{ response.status }} {{ response.status_text }}
{% for key, val in response.headers.items %}{{ key }}: {{ val }}
{% endfor %}
{{ content }}{% endautoescape %}

View File

@ -0,0 +1,3 @@
HTML:
{{ content }}

View File

View File

@ -0,0 +1,17 @@
from django.template import Library
from urlparse import urlparse, urlunparse
from urllib import quote
register = Library()
def add_query_param(url, param):
(key, val) = param.split('=')
param = '%s=%s' % (key, quote(val))
(scheme, netloc, path, params, query, fragment) = urlparse(url)
if query:
query += "&" + param
else:
query = param
return urlunparse((scheme, netloc, path, params, query, fragment))
register.filter('add_query_param', add_query_param)

View File

@ -0,0 +1,100 @@
"""Adds the custom filter 'urlize_quoted_links'
This is identical to the built-in filter 'urlize' with the exception that
single and double quotes are permitted as leading or trailing punctuation.
"""
# Almost all of this code is copied verbatim from django.utils.html
# LEADING_PUNCTUATION and TRAILING_PUNCTUATION have been modified
import re
import string
from django.utils.safestring import SafeData, mark_safe
from django.utils.encoding import force_unicode
from django.utils.http import urlquote
from django.utils.html import escape
from django import template
# Configuration for urlize() function.
LEADING_PUNCTUATION = ['(', '<', '&lt;', '"', "'"]
TRAILING_PUNCTUATION = ['.', ',', ')', '>', '\n', '&gt;', '"', "'"]
# List of possible strings used for bullets in bulleted lists.
DOTS = ['&middot;', '*', '\xe2\x80\xa2', '&#149;', '&bull;', '&#8226;']
unencoded_ampersands_re = re.compile(r'&(?!(\w+|#\d+);)')
word_split_re = re.compile(r'(\s+)')
punctuation_re = re.compile('^(?P<lead>(?:%s)*)(?P<middle>.*?)(?P<trail>(?:%s)*)$' % \
('|'.join([re.escape(x) for x in LEADING_PUNCTUATION]),
'|'.join([re.escape(x) for x in TRAILING_PUNCTUATION])))
simple_email_re = re.compile(r'^\S+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9._-]+$')
link_target_attribute_re = re.compile(r'(<a [^>]*?)target=[^\s>]+')
html_gunk_re = re.compile(r'(?:<br clear="all">|<i><\/i>|<b><\/b>|<em><\/em>|<strong><\/strong>|<\/?smallcaps>|<\/?uppercase>)', re.IGNORECASE)
hard_coded_bullets_re = re.compile(r'((?:<p>(?:%s).*?[a-zA-Z].*?</p>\s*)+)' % '|'.join([re.escape(x) for x in DOTS]), re.DOTALL)
trailing_empty_content_re = re.compile(r'(?:<p>(?:&nbsp;|\s|<br \/>)*?</p>\s*)+\Z')
def urlize_quoted_links(text, trim_url_limit=None, nofollow=False, autoescape=True):
"""
Converts any URLs in text into clickable links.
Works on http://, https://, www. links and links ending in .org, .net or
.com. Links can have trailing punctuation (periods, commas, close-parens)
and leading punctuation (opening parens) and it'll still do the right
thing.
If trim_url_limit is not None, the URLs in link text longer than this limit
will truncated to trim_url_limit-3 characters and appended with an elipsis.
If nofollow is True, the URLs in link text will get a rel="nofollow"
attribute.
If autoescape is True, the link text and URLs will get autoescaped.
"""
trim_url = lambda x, limit=trim_url_limit: limit is not None and (len(x) > limit and ('%s...' % x[:max(0, limit - 3)])) or x
safe_input = isinstance(text, SafeData)
words = word_split_re.split(force_unicode(text))
nofollow_attr = nofollow and ' rel="nofollow"' or ''
for i, word in enumerate(words):
match = None
if '.' in word or '@' in word or ':' in word:
match = punctuation_re.match(word)
if match:
lead, middle, trail = match.groups()
# Make URL we want to point to.
url = None
if middle.startswith('http://') or middle.startswith('https://'):
url = urlquote(middle, safe='/&=:;#?+*')
elif middle.startswith('www.') or ('@' not in middle and \
middle and middle[0] in string.ascii_letters + string.digits and \
(middle.endswith('.org') or middle.endswith('.net') or middle.endswith('.com'))):
url = urlquote('http://%s' % middle, safe='/&=:;#?+*')
elif '@' in middle and not ':' in middle and simple_email_re.match(middle):
url = 'mailto:%s' % middle
nofollow_attr = ''
# Make link.
if url:
trimmed = trim_url(middle)
if autoescape and not safe_input:
lead, trail = escape(lead), escape(trail)
url, trimmed = escape(url), escape(trimmed)
middle = '<a href="%s"%s>%s</a>' % (url, nofollow_attr, trimmed)
words[i] = mark_safe('%s%s%s' % (lead, middle, trail))
else:
if safe_input:
words[i] = mark_safe(word)
elif autoescape:
words[i] = escape(word)
elif safe_input:
words[i] = mark_safe(word)
elif autoescape:
words[i] = escape(word)
return u''.join(words)
#urlize_quoted_links.needs_autoescape = True
urlize_quoted_links.is_safe = True
# Register urlize_quoted_links as a custom filter
# http://docs.djangoproject.com/en/dev/howto/custom-template-tags/
register = template.Library()
register.filter(urlize_quoted_links)

170
flywheel/utils.py Normal file
View File

@ -0,0 +1,170 @@
import re
import xml.etree.ElementTree as ET
from django.utils.encoding import smart_unicode
from django.utils.xmlutils import SimplerXMLGenerator
try:
import cStringIO as StringIO
except ImportError:
import StringIO
# From piston
def coerce_put_post(request):
"""
Django doesn't particularly understand REST.
In case we send data over PUT, Django won't
actually look at the data and load it. We need
to twist its arm here.
The try/except abominiation here is due to a bug
in mod_python. This should fix it.
"""
if request.method != 'PUT':
return
# Bug fix: if _load_post_and_files has already been called, for
# example by middleware accessing request.POST, the below code to
# pretend the request is a POST instead of a PUT will be too late
# to make a difference. Also calling _load_post_and_files will result
# in the following exception:
# AttributeError: You cannot set the upload handlers after the upload has been processed.
# The fix is to check for the presence of the _post field which is set
# the first time _load_post_and_files is called (both by wsgi.py and
# modpython.py). If it's set, the request has to be 'reset' to redo
# the query value parsing in POST mode.
if hasattr(request, '_post'):
del request._post
del request._files
try:
request.method = "POST"
request._load_post_and_files()
request.method = "PUT"
except AttributeError:
request.META['REQUEST_METHOD'] = 'POST'
request._load_post_and_files()
request.META['REQUEST_METHOD'] = 'PUT'
request.PUT = request.POST
# From http://www.koders.com/python/fidB6E125C586A6F49EAC38992CF3AFDAAE35651975.aspx?s=mdef:xml
#class object_dict(dict):
# """object view of dict, you can
# >>> a = object_dict()
# >>> a.fish = 'fish'
# >>> a['fish']
# 'fish'
# >>> a['water'] = 'water'
# >>> a.water
# 'water'
# >>> a.test = {'value': 1}
# >>> a.test2 = object_dict({'name': 'test2', 'value': 2})
# >>> a.test, a.test2.name, a.test2.value
# (1, 'test2', 2)
# """
# def __init__(self, initd=None):
# if initd is None:
# initd = {}
# dict.__init__(self, initd)
#
# def __getattr__(self, item):
# d = self.__getitem__(item)
# # if value is the only key in object, you can omit it
# if isinstance(d, dict) and 'value' in d and len(d) == 1:
# return d['value']
# else:
# return d
#
# def __setattr__(self, item, value):
# self.__setitem__(item, value)
# From xml2dict
class XML2Dict(object):
def __init__(self):
pass
def _parse_node(self, node):
node_tree = {}
# Save attrs and text, hope there will not be a child with same name
if node.text:
node_tree = node.text
for (k,v) in node.attrib.items():
k,v = self._namespace_split(k, v)
node_tree[k] = v
#Save childrens
for child in node.getchildren():
tag, tree = self._namespace_split(child.tag, self._parse_node(child))
if tag not in node_tree: # the first time, so store it in dict
node_tree[tag] = tree
continue
old = node_tree[tag]
if not isinstance(old, list):
node_tree.pop(tag)
node_tree[tag] = [old] # multi times, so change old dict to a list
node_tree[tag].append(tree) # add the new one
return node_tree
def _namespace_split(self, tag, value):
"""
Split the tag '{http://cs.sfsu.edu/csc867/myscheduler}patients'
ns = http://cs.sfsu.edu/csc867/myscheduler
name = patients
"""
result = re.compile("\{(.*)\}(.*)").search(tag)
if result:
print tag
value.namespace, tag = result.groups()
return (tag, value)
def parse(self, file):
"""parse a xml file to a dict"""
f = open(file, 'r')
return self.fromstring(f.read())
def fromstring(self, s):
"""parse a string"""
t = ET.fromstring(s)
unused_root_tag, root_tree = self._namespace_split(t.tag, self._parse_node(t))
return root_tree
def xml2dict(input):
return XML2Dict().fromstring(input)
# Piston:
class XMLEmitter():
def _to_xml(self, xml, data):
if isinstance(data, (list, tuple)):
for item in data:
xml.startElement("list-item", {})
self._to_xml(xml, item)
xml.endElement("list-item")
elif isinstance(data, dict):
for key, value in data.iteritems():
xml.startElement(key, {})
self._to_xml(xml, value)
xml.endElement(key)
else:
xml.characters(smart_unicode(data))
def dict2xml(self, data):
stream = StringIO.StringIO()
xml = SimplerXMLGenerator(stream, "utf-8")
xml.startDocument()
xml.startElement("root", {})
self._to_xml(xml, data)
xml.endElement("root")
xml.endDocument()
return stream.getvalue()
def dict2xml(input):
return XMLEmitter().dict2xml(input)