diff options
Diffstat (limited to 'appengine_django')
63 files changed, 3072 insertions, 0 deletions
diff --git a/appengine_django/__init__.py b/appengine_django/__init__.py new file mode 100644 index 0000000..629695d --- /dev/null +++ b/appengine_django/__init__.py @@ -0,0 +1,528 @@ +#!/usr/bin/python2.5 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Support for integrating a Django project with the appengine infrastructure. + +This requires Django 1.0beta1 or greater. + +This module enables you to use the Django manage.py utility and *some* of it's +subcommands. View the help of manage.py for exact details. + +Additionally this module takes care of initialising the datastore (and a test +datastore) so that the Django test infrastructure can be used for your +appengine project. + +To use this module add the following two lines to your main.py and manage.py +scripts at the end of your imports: + from appengine_django import InstallAppengineHelperForDjango + InstallAppengineHelperForDjango() + +If you would like to use a version of Django other than that provided by the +system all you need to do is include it in a directory just above this helper, +eg: + appengine_django/__init__.py - This file + django/... - your private copy of Django. +""" + +import logging +import os +import re +import sys +import unittest +import zipfile + + +DIR_PATH = os.path.abspath(os.path.dirname(__file__)) +PARENT_DIR = os.path.dirname(DIR_PATH) +if PARENT_DIR.endswith(".zip"): + # Check for appengine_django itself being in a zipfile. + PARENT_DIR = os.path.dirname(PARENT_DIR) + +# Add this project to the start of sys path to enable direct imports. +sys.path = [PARENT_DIR,] + sys.path + +# Try to import the appengine code from the system path. +try: + from google.appengine.api import apiproxy_stub_map +except ImportError, e: + # Not on the system path. Build a list of alternative paths where it may be. + # First look within the project for a local copy, then look for where the Mac + # OS SDK installs it. + paths = [os.path.join(PARENT_DIR, '.google_appengine'), + os.path.join(PARENT_DIR, 'google_appengine'), + '/usr/local/google_appengine'] + # Then if on windows, look for where the Windows SDK installed it. + for path in os.environ.get('PATH', '').split(';'): + path = path.rstrip('\\') + if path.endswith('google_appengine'): + paths.append(path) + try: + from win32com.shell import shell + from win32com.shell import shellcon + id_list = shell.SHGetSpecialFolderLocation( + 0, shellcon.CSIDL_PROGRAM_FILES) + program_files = shell.SHGetPathFromIDList(id_list) + paths.append(os.path.join(program_files, 'Google', + 'google_appengine')) + except ImportError, e: + # Not windows. + pass + # Loop through all possible paths and look for the SDK dir. + SDK_PATH = None + for sdk_path in paths: + if os.path.exists(sdk_path): + SDK_PATH = os.path.realpath(sdk_path) + break + if SDK_PATH is None: + # The SDK could not be found in any known location. + sys.stderr.write("The Google App Engine SDK could not be found!\n") + sys.stderr.write("See README for installation instructions.\n") + sys.exit(1) + if SDK_PATH == os.path.join(PARENT_DIR, 'google_appengine'): + logging.warn('Loading the SDK from the \'google_appengine\' subdirectory ' + 'is now deprecated!') + logging.warn('Please move the SDK to a subdirectory named ' + '\'.google_appengine\' instead.') + logging.warn('See README for further details.') + # Add the SDK and the libraries within it to the system path. + EXTRA_PATHS = [ + SDK_PATH, + os.path.join(SDK_PATH, 'lib', 'antlr3'), + os.path.join(SDK_PATH, 'lib', 'django'), + os.path.join(SDK_PATH, 'lib', 'webob'), + os.path.join(SDK_PATH, 'lib', 'yaml', 'lib'), + ] + # Add SDK paths at the start of sys.path, but after the local directory which + # was added to the start of sys.path on line 50 above. The local directory + # must come first to allow the local imports to override the SDK and + # site-packages directories. + sys.path = sys.path[0:1] + EXTRA_PATHS + sys.path[1:] + from google.appengine.api import apiproxy_stub_map + +# Look for a zipped copy of Django. +have_django_zip = False +django_zip_path = os.path.join(PARENT_DIR, 'django.zip') +if os.path.exists(django_zip_path): + have_django_zip = True + sys.path.insert(1, django_zip_path) + +# Remove the standard version of Django if a local copy has been provided. +if have_django_zip or os.path.exists(os.path.join(PARENT_DIR, 'django')): + for k in [k for k in sys.modules if k.startswith('django')]: + del sys.modules[k] + +# Must set this env var *before* importing any more of Django. +os.environ['DJANGO_SETTINGS_MODULE'] = 'settings' + +from django import VERSION +from django.conf import settings + +from google.appengine.api import yaml_errors + +# Flags made available this module +appid = None +have_appserver = False + +# Hide everything other than the flags above and the install function. +__all__ = ("appid", "have_appserver", "have_django_zip", + "django_zip_path", "InstallAppengineHelperForDjango") + + +INCOMPATIBLE_COMMANDS = ["adminindex", "createcachetable", "dbshell", + "inspectdb", "runfcgi", "syncdb", "validate"] + + +def LoadAppengineEnvironment(): + """Loads the appengine environment. + + Returns: + This function has no return value, but it sets the following parameters on + this package: + - appid: The name of the application. + - have_appserver: Boolean parameter which is True if the code is being run + from within the appserver environment. + """ + global appid, have_appserver + + # Detect if we are running under an appserver. + have_appserver = False + stub = apiproxy_stub_map.apiproxy.GetStub("datastore_v3") + if stub: + have_appserver = True + + # Load the application identifier. + if have_appserver: + appid = os.environ.get("APPLICATION_ID", "unknown") + else: + # Running as manage.py script, read from config file. + try: + from google.appengine.tools import dev_appserver + appconfig, unused_matcher = dev_appserver.LoadAppConfig(PARENT_DIR, {}) + appid = appconfig.application + except (ImportError, yaml_errors.EventListenerYAMLError), e: + logging.warn("Could not read the Application ID from app.yaml. " + "This may break things in unusual ways!") + # Something went wrong. + appid = "unknown" + + logging.debug("Loading application '%s' %s an appserver" % + (appid, have_appserver and "with" or "without")) + + +def InstallAppengineDatabaseBackend(): + """Installs the appengine database backend into Django. + + The appengine database lives in the db/ subdirectory of this package, but is + known as "appengine" to Django. This function installs the module where + Django expects to find its database backends. + """ + from appengine_django import db + sys.modules['django.db.backends.appengine'] = db + logging.debug("Installed appengine database backend") + + +def InstallGoogleMemcache(): + """Installs the Google memcache into Django. + + By default django tries to import standard memcache module. + Because appengine memcache is API compatible with Python memcache module, + we can trick Django to think it is installed and to use it. + + Now you can use CACHE_BACKEND = 'memcached://' in settings.py. IP address + and port number are not required. + """ + from google.appengine.api import memcache + sys.modules['memcache'] = memcache + logging.debug("Installed App Engine memcache backend") + + +def InstallDjangoModuleReplacements(): + """Replaces internal Django modules with App Engine compatible versions.""" + + # Replace the session module with a partial replacement overlay using + # __path__ so that portions not replaced will fall through to the original + # implementation. + try: + from django.contrib import sessions + orig_path = sessions.__path__[0] + sessions.__path__.insert(0, os.path.join(DIR_PATH, 'sessions')) + from django.contrib.sessions import backends + backends.__path__.append(os.path.join(orig_path, 'backends')) + except ImportError: + logging.debug("No Django session support available") + + # Replace incompatible dispatchers. + import django.core.signals + import django.db + import django.dispatch.dispatcher + + # Rollback occurs automatically on Google App Engine. Disable the Django + # rollback handler. + CheckedException = KeyError + def _disconnectSignal(): + django.core.signals.got_request_exception.disconnect( + django.db._rollback_on_exception) + + try: + _disconnectSignal() + except CheckedException, e: + logging.debug("Django rollback handler appears to be already disabled.") + +def PatchDjangoSerializationModules(): + """Monkey patches the Django serialization modules. + + The standard Django serialization modules to not correctly handle the + datastore models provided by this package. This method installs replacements + for selected modules and methods to give Django the capability to correctly + serialize and deserialize datastore models. + """ + # These can't be imported until InstallAppengineDatabaseBackend has run. + from django.core.serializers import python + from appengine_django.serializer.python import Deserializer + if not hasattr(settings, "SERIALIZATION_MODULES"): + settings.SERIALIZATION_MODULES = {} + base_module = "appengine_django" + settings.SERIALIZATION_MODULES["xml"] = "%s.serializer.xml" % base_module + python.Deserializer = Deserializer + PatchDeserializedObjectClass() + DisableModelValidation() + logging.debug("Installed appengine json and python serialization modules") + + +def PatchDeserializedObjectClass(): + """Patches the DeserializedObject class. + + The default implementation calls save directly on the django Model base + class to avoid pre-save handlers. The model class provided by this package + is not derived from the Django Model class and therefore must be called + directly. + + Additionally we need to clear the internal _parent attribute as it may + contain a FakeParent class that is used to deserialize instances without + needing to load the parent instance itself. See the PythonDeserializer for + more details. + """ + # This can't be imported until InstallAppengineDatabaseBackend has run. + from django.core.serializers import base + class NewDeserializedObject(base.DeserializedObject): + def save(self, save_m2m=True): + self.object.save() + self.object._parent = None + base.DeserializedObject = NewDeserializedObject + logging.debug("Replacement DeserializedObject class installed") + +def DisableModelValidation(): + """Disables Django's model validation routines. + + The model validation is primarily concerned with validating foreign key + references. There is no equivalent checking code for datastore References at + this time. + + Validation needs to be disabled or serialization/deserialization will fail. + """ + from django.core.management import validation + validation.get_validation_errors = lambda x, y=0: 0 + logging.debug("Django SQL model validation disabled") + +def CleanupDjangoSettings(): + """Removes incompatible entries from the django settings module.""" + + # Ensure this module is installed as an application. + apps = getattr(settings, "INSTALLED_APPS", ()) + found = False + for app in apps: + if app.endswith("appengine_django"): + found = True + break + if not found: + logging.warn("appengine_django module is not listed as an application!") + apps += ("appengine_django",) + setattr(settings, "INSTALLED_APPS", apps) + logging.info("Added 'appengine_django' as an application") + + # Ensure the database backend is appropriately configured. + dbe = getattr(settings, "DATABASE_ENGINE", "") + if dbe != "appengine": + settings.DATABASE_ENGINE = "appengine" + logging.warn("DATABASE_ENGINE is not configured as 'appengine'. " + "Value overriden!") + for var in ["NAME", "USER", "PASSWORD", "HOST", "PORT"]: + val = getattr(settings, "DATABASE_%s" % var, "") + if val: + setattr(settings, "DATABASE_%s" % var, "") + logging.warn("DATABASE_%s should be blank. Value overriden!") + + # Remove incompatible middleware modules. + mw_mods = list(getattr(settings, "MIDDLEWARE_CLASSES", ())) + disallowed_middleware_mods = ( + 'django.middleware.doc.XViewMiddleware',) + for modname in mw_mods[:]: + if modname in disallowed_middleware_mods: + # Currently only the CommonMiddleware has been ported. As other base + # modules are converted, remove from the disallowed_middleware_mods + # tuple. + mw_mods.remove(modname) + logging.warn("Middleware module '%s' is not compatible. Removed!" % + modname) + setattr(settings, "MIDDLEWARE_CLASSES", tuple(mw_mods)) + + # Remove incompatible application modules + app_mods = list(getattr(settings, "INSTALLED_APPS", ())) + disallowed_apps = ( + 'django.contrib.contenttypes', + 'django.contrib.sites',) + for app in app_mods[:]: + if app in disallowed_apps: + app_mods.remove(app) + logging.warn("Application module '%s' is not compatible. Removed!" % app) + setattr(settings, "INSTALLED_APPS", tuple(app_mods)) + + # Remove incompatible session backends. + session_backend = getattr(settings, "SESSION_ENGINE", "") + if session_backend.endswith("file"): + logging.warn("File session backend is not compatible. Overriden " + "to use db backend!") + setattr(settings, "SESSION_ENGINE", "django.contrib.sessions.backends.db") + + +def ModifyAvailableCommands(): + """Removes incompatible commands and installs replacements where possible.""" + if have_appserver: + # Commands are not used when running from an appserver. + return + from django.core import management + project_directory = os.path.join(__path__[0], "../") + if have_django_zip: + FindCommandsInZipfile.orig = management.find_commands + management.find_commands = FindCommandsInZipfile + management.get_commands() + # Replace startapp command which is set by previous call to get_commands(). + from appengine_django.management.commands.startapp import ProjectCommand + management._commands['startapp'] = ProjectCommand(project_directory) + RemoveCommands(management._commands) + logging.debug("Removed incompatible Django manage.py commands") + + +def FindCommandsInZipfile(management_dir): + """ + Given a path to a management directory, returns a list of all the command + names that are available. + + This implementation also works when Django is loaded from a zip. + + Returns an empty list if no commands are defined. + """ + zip_marker = ".zip%s" % os.sep + if zip_marker not in management_dir: + return FindCommandsInZipfile.orig(management_dir) + + # Django is sourced from a zipfile, ask zip module for a list of files. + filename, path = management_dir.split(zip_marker) + zipinfo = zipfile.ZipFile("%s.zip" % filename) + + # Add commands directory to management path. + path = os.path.join(path, "commands") + + # The zipfile module returns paths in the format of the operating system + # that created the zipfile! This may not match the path to the zipfile + # itself. Convert operating system specific characters to a standard + # character (#) to compare paths to work around this. + path_normalise = re.compile(r"[/\\]") + path = path_normalise.sub("#", path) + def _IsCmd(t): + """Returns true if t matches the criteria for a command module.""" + filename = os.path.basename(t) + t = path_normalise.sub("#", t) + if not t.startswith(path): + return False + if filename.startswith("_") or not t.endswith(".py"): + return False + return True + + return [os.path.basename(f)[:-3] for f in zipinfo.namelist() if _IsCmd(f)] + + +def RemoveCommands(command_dict): + """Removes incompatible commands from the specified command dictionary.""" + for cmd in command_dict.keys(): + if cmd.startswith("sql"): + del command_dict[cmd] + elif cmd in INCOMPATIBLE_COMMANDS: + del command_dict[cmd] + + +def InstallReplacementImpModule(): + """Install a replacement for the imp module removed by the appserver. + + This is only to find mangement modules provided by applications. + """ + if not have_appserver: + return + modname = 'appengine_django.replacement_imp' + imp_mod = __import__(modname, {}, [], ['']) + sys.modules['imp'] = imp_mod + logging.debug("Installed replacement imp module") + + +def InstallAppengineHelperForDjango(): + """Installs and Patches all of the classes/methods required for integration. + + If the variable DEBUG_APPENGINE_DJANGO is set in the environment verbose + logging of the actions taken will be enabled. + """ + if VERSION < (1, 0, None): + logging.error("Django 1.0 or greater is required!") + sys.exit(1) + + if os.getenv("DEBUG_APPENGINE_DJANGO"): + logging.getLogger().setLevel(logging.DEBUG) + else: + logging.getLogger().setLevel(logging.INFO) + logging.debug("Loading the Google App Engine Helper for Django...") + + # Force Django to reload its settings. + settings._target = None + + LoadAppengineEnvironment() + InstallReplacementImpModule() + InstallAppengineDatabaseBackend() + InstallModelForm() + InstallGoogleMemcache() + InstallDjangoModuleReplacements() + PatchDjangoSerializationModules() + CleanupDjangoSettings() + ModifyAvailableCommands() + InstallGoogleSMTPConnection() + InstallAuthentication() + + logging.debug("Successfully loaded the Google App Engine Helper for Django.") + + +def InstallGoogleSMTPConnection(): + from appengine_django import mail as gmail + from django.core import mail + logging.debug("Installing Google Email Adapter for Django") + mail.SMTPConnection = gmail.GoogleSMTPConnection + mail.mail_admins = gmail.mail_admins + mail.mail_managers = gmail.mail_managers + + +def InstallAuthentication(): + if "django.contrib.auth" not in settings.INSTALLED_APPS: + return + try: + from appengine_django.auth import models as helper_models + from django.contrib.auth import models + models.User = helper_models.User + models.Group = helper_models.Group + models.Permission = helper_models.Permission + models.Message = helper_models.Message + from django.contrib.auth import middleware as django_middleware + from appengine_django.auth.middleware import AuthenticationMiddleware + django_middleware.AuthenticationMiddleware = AuthenticationMiddleware + from django.contrib.auth import decorators as django_decorators + from appengine_django.auth.decorators import login_required + django_decorators.login_required = login_required + from django.contrib import auth as django_auth + from django.contrib.auth import tests as django_tests + django_auth.suite = unittest.TestSuite + django_tests.suite = unittest.TestSuite + logging.debug("Installing authentication framework") + except ImportError: + logging.debug("No Django authentication support available") + + +def InstallModelForm(): + """Replace Django ModelForm with the AppEngine ModelForm.""" + # This MUST happen as early as possible, but after any auth model patching. + from google.appengine.ext.db import djangoforms as aeforms + try: + # pre 1.0 + from django import newforms as forms + except ImportError: + from django import forms + + forms.ModelForm = aeforms.ModelForm + + # Extend ModelForm with support for EmailProperty + # TODO: This should be submitted to the main App Engine SDK. + from google.appengine.ext.db import EmailProperty + def get_form_field(self, **kwargs): + """Return a Django form field appropriate for an email property.""" + defaults = {'form_class': forms.EmailField} + defaults.update(kwargs) + return super(EmailProperty, self).get_form_field(**defaults) + EmailProperty.get_form_field = get_form_field diff --git a/appengine_django/__init__.pyc b/appengine_django/__init__.pyc Binary files differnew file mode 100644 index 0000000..12557cb --- /dev/null +++ b/appengine_django/__init__.pyc diff --git a/appengine_django/auth/__init__.py b/appengine_django/auth/__init__.py new file mode 100644 index 0000000..d2db207 --- /dev/null +++ b/appengine_django/auth/__init__.py @@ -0,0 +1,25 @@ +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Authentication module that mimics the behavior of Django's authentication +implementation. + +Limitations: + - all user permissions methods are not available (requires contenttypes) +""" + +from django.template import add_to_builtins + +add_to_builtins('appengine_django.auth.templatetags') diff --git a/appengine_django/auth/__init__.pyc b/appengine_django/auth/__init__.pyc Binary files differnew file mode 100644 index 0000000..4d1edc3 --- /dev/null +++ b/appengine_django/auth/__init__.pyc diff --git a/appengine_django/auth/decorators.py b/appengine_django/auth/decorators.py new file mode 100644 index 0000000..d897c24 --- /dev/null +++ b/appengine_django/auth/decorators.py @@ -0,0 +1,31 @@ +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Decorators for the authentication framework.""" + +from django.http import HttpResponseRedirect + +from google.appengine.api import users + + +def login_required(function): + """Implementation of Django's login_required decorator. + + The login redirect URL is always set to request.path + """ + def login_required_wrapper(request, *args, **kw): + if request.user.is_authenticated(): + return function(request, *args, **kw) + return HttpResponseRedirect(users.create_login_url(request.path)) + return login_required_wrapper diff --git a/appengine_django/auth/decorators.pyc b/appengine_django/auth/decorators.pyc Binary files differnew file mode 100644 index 0000000..477c819 --- /dev/null +++ b/appengine_django/auth/decorators.pyc diff --git a/appengine_django/auth/middleware.py b/appengine_django/auth/middleware.py new file mode 100644 index 0000000..a727e47 --- /dev/null +++ b/appengine_django/auth/middleware.py @@ -0,0 +1,36 @@ +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from django.contrib.auth.models import AnonymousUser + +from google.appengine.api import users + +from appengine_django.auth.models import User + + +class LazyUser(object): + def __get__(self, request, obj_type=None): + if not hasattr(request, '_cached_user'): + user = users.get_current_user() + if user: + request._cached_user = User.get_djangouser_for_user(user) + else: + request._cached_user = AnonymousUser() + return request._cached_user + + +class AuthenticationMiddleware(object): + def process_request(self, request): + request.__class__.user = LazyUser() + return None diff --git a/appengine_django/auth/middleware.pyc b/appengine_django/auth/middleware.pyc Binary files differnew file mode 100644 index 0000000..ff36de8 --- /dev/null +++ b/appengine_django/auth/middleware.pyc diff --git a/appengine_django/auth/models.py b/appengine_django/auth/models.py new file mode 100644 index 0000000..d93e240 --- /dev/null +++ b/appengine_django/auth/models.py @@ -0,0 +1,172 @@ +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +App Engine compatible models for the Django authentication framework. +""" + +from django.core import mail +from django.core.exceptions import ImproperlyConfigured +from django.db import models +from django.utils.encoding import smart_str +import urllib + +from django.db.models.manager import EmptyManager + +from google.appengine.api import users +from google.appengine.ext import db + +from appengine_django.models import BaseModel + + +class User(BaseModel): + """A model with the same attributes and methods as a Django user model. + + The model has two additions. The first addition is a 'user' attribute which + references a App Engine user. The second is the 'get_djangouser_for_user' + classmethod that should be used to retrieve a DjangoUser instance from a App + Engine user object. + """ + user = db.UserProperty(required=True) + username = db.StringProperty(required=True) + first_name = db.StringProperty() + last_name = db.StringProperty() + email = db.EmailProperty() + password = db.StringProperty() + is_staff = db.BooleanProperty(default=False, required=True) + is_active = db.BooleanProperty(default=True, required=True) + is_superuser = db.BooleanProperty(default=False, required=True) + last_login = db.DateTimeProperty(auto_now_add=True, required=True) + date_joined = db.DateTimeProperty(auto_now_add=True, required=True) + groups = EmptyManager() + user_permissions = EmptyManager() + + def __unicode__(self): + return self.username + + def __str__(self): + return unicode(self).encode('utf-8') + + @classmethod + def get_djangouser_for_user(cls, user): + query = cls.all().filter("user =", user) + if query.count() == 0: + django_user = cls(user=user, email=user.email(), username=user.nickname()) + django_user.save() + else: + django_user = query.get() + return django_user + + def set_password(self, raw_password): + raise NotImplementedError + + def check_password(self, raw_password): + raise NotImplementedError + + def set_unusable_password(self): + raise NotImplementedError + + def has_usable_password(self): + raise NotImplementedError + + def get_group_permissions(self): + return self.user_permissions + + def get_all_permissions(self): + return self.user_permissions + + def has_perm(self, perm): + return False + + def has_perms(self, perm_list): + return False + + def has_module_perms(self, module): + return False + + def get_and_delete_messages(self): + """Gets and deletes messages for this user""" + msgs = [] + for msg in self.message_set: + msgs.append(msg) + msg.delete() + return msgs + + def is_anonymous(self): + """Always return False""" + return False + + def is_authenticated(self): + """Always return True""" + return True + + def get_absolute_url(self): + return "/users/%s/" % urllib.quote(smart_str(self.username)) + + def get_full_name(self): + full_name = u'%s %s' % (self.first_name, self.last_name) + return full_name.strip() + + def email_user(self, subject, message, from_email): + """Sends an email to this user. + + According to the App Engine email API the from_email must be the + email address of a registered administrator for the application. + """ + mail.send_mail(subject, + message, + from_email, + [self.email]) + + def get_profile(self): + """ + Returns site-specific profile for this user. Raises + SiteProfileNotAvailable if this site does not allow profiles. + + When using the App Engine authentication framework, users are created + automatically. + """ + from django.contrib.auth.models import SiteProfileNotAvailable + if not hasattr(self, '_profile_cache'): + from django.conf import settings + if not hasattr(settings, "AUTH_PROFILE_MODULE"): + raise SiteProfileNotAvailable + try: + app_label, model_name = settings.AUTH_PROFILE_MODULE.split('.') + model = models.get_model(app_label, model_name) + self._profile_cache = model.all().filter("user =", self).get() + if not self._profile_cache: + raise model.DoesNotExist + except (ImportError, ImproperlyConfigured): + raise SiteProfileNotAvailable + return self._profile_cache + + +class Group(BaseModel): + """Group model not fully implemented yet.""" + # TODO: Implement this model, requires contenttypes + name = db.StringProperty() + permissions = EmptyManager() + + +class Message(BaseModel): + """User message model""" + user = db.ReferenceProperty(User) + message = db.TextProperty() + + +class Permission(BaseModel): + """Permission model not fully implemented yet.""" + # TODO: Implement this model, requires contenttypes + name = db.StringProperty() diff --git a/appengine_django/auth/models.pyc b/appengine_django/auth/models.pyc Binary files differnew file mode 100644 index 0000000..0a73b9c --- /dev/null +++ b/appengine_django/auth/models.pyc diff --git a/appengine_django/auth/templatetags.py b/appengine_django/auth/templatetags.py new file mode 100644 index 0000000..8237890 --- /dev/null +++ b/appengine_django/auth/templatetags.py @@ -0,0 +1,62 @@ +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Template tags for the auth module. These are inserted into Django as "built-in" +tags so you do not need to use the load statement in your template to get +access to them. +""" + +from django.template import Library +from django.template import Node + +from google.appengine.api import users + + +class AuthLoginUrlsNode(Node): + """Template node that creates an App Engine login or logout URL. + + If create_login_url is True the App Engine's login URL is rendered into + the template, otherwise the logout URL. + """ + def __init__(self, create_login_url, redirect): + self.redirect = redirect + self.create_login_url = create_login_url + + def render(self, context): + if self.create_login_url: + return users.create_login_url(self.redirect) + else: + return users.create_logout_url(self.redirect) + + +def auth_login_urls(parser, token): + """Template tag registered as 'auth_login_url' and 'auth_logout_url' + when the module is imported. + + Both tags take an optional argument that specifies the redirect URL and + defaults to '/'. + """ + bits = list(token.split_contents()) + if len(bits) == 2: + redirect = bits[1] + else: + redirect = "/" + login = bits[0] == "auth_login_url" + return AuthLoginUrlsNode(login, redirect) + + +register = Library() +register.tag("auth_login_url", auth_login_urls) +register.tag("auth_logout_url", auth_login_urls) diff --git a/appengine_django/auth/templatetags.pyc b/appengine_django/auth/templatetags.pyc Binary files differnew file mode 100644 index 0000000..7121f59 --- /dev/null +++ b/appengine_django/auth/templatetags.pyc diff --git a/appengine_django/auth/tests.py b/appengine_django/auth/tests.py new file mode 100644 index 0000000..20aecfa --- /dev/null +++ b/appengine_django/auth/tests.py @@ -0,0 +1,58 @@ +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +BASIC_TESTS = """ +>>> from google.appengine.api import users +>>> from models import User, AnonymousUser +>>> appengine_user = users.User("test@example.com") +>>> django_user = User.get_djangouser_for_user(appengine_user) +>>> django_user.email == appengine_user.email() +True +>>> django_user.username == appengine_user.nickname() +True +>>> django_user.user == appengine_user +True + +>>> django_user.username = 'test2' +>>> key = django_user.save() +>>> django_user.username == 'test2' +True + +>>> django_user2 = User.get_djangouser_for_user(appengine_user) +>>> django_user2 == django_user +True + +>>> django_user.is_authenticated() +True +>>> django_user.is_staff +False +>>> django_user.is_active +True + +>>> a = AnonymousUser() +>>> a.is_authenticated() +False +>>> a.is_staff +False +>>> a.is_active +False +>>> a.groups.all() +[] +>>> a.user_permissions.all() +[] + + +""" + +__test__ = {'BASIC_TESTS': BASIC_TESTS} diff --git a/appengine_django/conf/app_template/__init__.py b/appengine_django/conf/app_template/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/appengine_django/conf/app_template/__init__.py diff --git a/appengine_django/conf/app_template/models.py b/appengine_django/conf/app_template/models.py new file mode 100644 index 0000000..4d7c5d0 --- /dev/null +++ b/appengine_django/conf/app_template/models.py @@ -0,0 +1,4 @@ +from appengine_django.models import BaseModel +from google.appengine.ext import db + +# Create your models here. diff --git a/appengine_django/conf/app_template/views.py b/appengine_django/conf/app_template/views.py new file mode 100644 index 0000000..60f00ef --- /dev/null +++ b/appengine_django/conf/app_template/views.py @@ -0,0 +1 @@ +# Create your views here. diff --git a/appengine_django/db/__init__.py b/appengine_django/db/__init__.py new file mode 100755 index 0000000..619bc78 --- /dev/null +++ b/appengine_django/db/__init__.py @@ -0,0 +1,20 @@ +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Explicitly set the name of this package to "appengine". +# +# The rationale for this is so that Django can refer to the database as +# "appengine" even though at a filesystem level it appears as the "db" package +# within the appengine_django package. +__name__ = "appengine" diff --git a/appengine_django/db/__init__.pyc b/appengine_django/db/__init__.pyc Binary files differnew file mode 100644 index 0000000..879eeaa --- /dev/null +++ b/appengine_django/db/__init__.pyc diff --git a/appengine_django/db/base.py b/appengine_django/db/base.py new file mode 100755 index 0000000..8a90182 --- /dev/null +++ b/appengine_django/db/base.py @@ -0,0 +1,150 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""This module looks after initialising the appengine api stubs.""" + +import logging +import os + +from appengine_django import appid +from appengine_django import have_appserver +from appengine_django.db.creation import DatabaseCreation + + +from django.db.backends import BaseDatabaseWrapper +from django.db.backends import BaseDatabaseFeatures +from django.db.backends import BaseDatabaseOperations + + +def get_datastore_paths(): + """Returns a tuple with the path to the datastore and history file. + + The datastore is stored in the same location as dev_appserver uses by + default, but the name is altered to be unique to this project so multiple + Django projects can be developed on the same machine in parallel. + + Returns: + (datastore_path, history_path) + """ + from google.appengine.tools import dev_appserver_main + datastore_path = dev_appserver_main.DEFAULT_ARGS['datastore_path'] + history_path = dev_appserver_main.DEFAULT_ARGS['history_path'] + datastore_path = datastore_path.replace("dev_appserver", "django_%s" % appid) + history_path = history_path.replace("dev_appserver", "django_%s" % appid) + return datastore_path, history_path + + +def get_test_datastore_paths(inmemory=True): + """Returns a tuple with the path to the test datastore and history file. + + If inmemory is true, (None, None) is returned to request an in-memory + datastore. If inmemory is false the path returned will be similar to the path + returned by get_datastore_paths but with a different name. + + Returns: + (datastore_path, history_path) + """ + if inmemory: + return None, None + datastore_path, history_path = get_datastore_paths() + datastore_path = datastore_path.replace("datastore", "testdatastore") + history_path = history_path.replace("datastore", "testdatastore") + return datastore_path, history_path + + +def destroy_datastore(datastore_path, history_path): + """Destroys the appengine datastore at the specified paths.""" + for path in [datastore_path, history_path]: + if not path: continue + try: + os.remove(path) + except OSError, e: + if e.errno != 2: + logging.error("Failed to clear datastore: %s" % e) + + +class DatabaseError(Exception): + """Stub class for database errors. Required by Django""" + pass + + +class IntegrityError(Exception): + """Stub class for database integrity errors. Required by Django""" + pass + + +class DatabaseFeatures(BaseDatabaseFeatures): + """Stub class to provide the feaures member expected by Django""" + pass + + +class DatabaseOperations(BaseDatabaseOperations): + """Stub class to provide the options member expected by Django""" + pass + + +class DatabaseWrapper(BaseDatabaseWrapper): + """App Engine database definition for Django. + + This "database" backend does not support any of the standard backend + operations. The only task that it performs is to setup the api stubs required + by the appengine libraries if they have not already been initialised by an + appserver. + """ + + def __init__(self, *args, **kwargs): + super(DatabaseWrapper, self).__init__(*args, **kwargs) + self.features = DatabaseFeatures() + self.ops = DatabaseOperations() + self.creation = DatabaseCreation(self) + self.use_test_datastore = kwargs.get("use_test_datastore", False) + self.test_datastore_inmemory = kwargs.get("test_datastore_inmemory", True) + if have_appserver: + return + self._setup_stubs() + + def _get_paths(self): + if self.use_test_datastore: + return get_test_datastore_paths(self.test_datastore_inmemory) + else: + return get_datastore_paths() + + def _setup_stubs(self): + # If this code is being run without an appserver (eg. via a django + # commandline flag) then setup a default stub environment. + from google.appengine.tools import dev_appserver_main + args = dev_appserver_main.DEFAULT_ARGS.copy() + args['datastore_path'], args['history_path'] = self._get_paths() + from google.appengine.tools import dev_appserver + dev_appserver.SetupStubs(appid, **args) + if self.use_test_datastore: + logging.debug("Configured API stubs for the test datastore") + else: + logging.debug("Configured API stubs for the development datastore") + + def flush(self): + """Helper function to remove the current datastore and re-open the stubs""" + destroy_datastore(*self._get_paths()) + self._setup_stubs() + + def close(self): + pass + + def _commit(self): + pass + + def cursor(self, *args): + pass diff --git a/appengine_django/db/base.pyc b/appengine_django/db/base.pyc Binary files differnew file mode 100644 index 0000000..8699add --- /dev/null +++ b/appengine_django/db/base.pyc diff --git a/appengine_django/db/creation.py b/appengine_django/db/creation.py new file mode 100755 index 0000000..74e3048 --- /dev/null +++ b/appengine_django/db/creation.py @@ -0,0 +1,37 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import logging + +from django.db.backends.creation import BaseDatabaseCreation + + +class DatabaseCreation(BaseDatabaseCreation): + + def create_test_db(self, *args, **kw): + """Destroys the test datastore. A new store will be recreated on demand""" + self.destroy_test_db() + self.connection.use_test_datastore = True + self.connection.flush() + + + def destroy_test_db(self, *args, **kw): + """Destroys the test datastore files.""" + from appengine_django.db.base import destroy_datastore + from appengine_django.db.base import get_test_datastore_paths + destroy_datastore(*get_test_datastore_paths()) + logging.debug("Destroyed test datastore") diff --git a/appengine_django/db/creation.pyc b/appengine_django/db/creation.pyc Binary files differnew file mode 100644 index 0000000..2055fb6 --- /dev/null +++ b/appengine_django/db/creation.pyc diff --git a/appengine_django/mail.py b/appengine_django/mail.py new file mode 100644 index 0000000..bf3e2dd --- /dev/null +++ b/appengine_django/mail.py @@ -0,0 +1,95 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +This module replaces the Django mail implementation with a version that sends +email via the mail API provided by Google App Engine. + +Multipart / HTML email is not yet supported. +""" + +import logging + +from django.core import mail +from django.core.mail import SMTPConnection +from django.conf import settings + +from google.appengine.api import mail as gmail + + +class GoogleSMTPConnection(SMTPConnection): + def __init__(self, host=None, port=None, username=None, password=None, + use_tls=None, fail_silently=False): + self.use_tls = (use_tls is not None) and use_tls or settings.EMAIL_USE_TLS + self.fail_silently = fail_silently + self.connection = None + + def open(self): + self.connection = True + + def close(self): + pass + + def _send(self, email_message): + """A helper method that does the actual sending.""" + if not email_message.to: + return False + try: + if (isinstance(email_message,gmail.EmailMessage)): + e = message + elif (isinstance(email_message,mail.EmailMessage)): + e = gmail.EmailMessage(sender=email_message.from_email, + to=email_message.to, + subject=email_message.subject, + body=email_message.body) + if email_message.extra_headers.get('Reply-To', None): + e.reply_to = email_message.extra_headers['Reply-To'] + if email_message.bcc: + e.bcc = list(email_message.bcc) + #TODO - add support for html messages and attachments... + e.send() + except: + if not self.fail_silently: + raise + return False + return True + + +def mail_admins(subject, message, fail_silently=False): + """Sends a message to the admins, as defined by the ADMINS setting.""" + _mail_group(settings.ADMINS, subject, message, fail_silently) + + +def mail_managers(subject, message, fail_silently=False): + """Sends a message to the managers, as defined by the MANAGERS setting.""" + _mail_group(settings.MANAGERS, subject, message, fail_silently) + + +def _mail_group(group, subject, message, fail_silently=False): + """Sends a message to an administrative group.""" + if group: + mail.send_mail(settings.EMAIL_SUBJECT_PREFIX + subject, message, + settings.SERVER_EMAIL, [a[1] for a in group], + fail_silently) + return + # If the group had no recipients defined, default to the App Engine admins. + try: + gmail.send_mail_to_admins(settings.SERVER_EMAIL, + settings.EMAIL_SUBJECT_PREFIX + subject, + message) + except: + if not fail_silently: + raise diff --git a/appengine_django/mail.pyc b/appengine_django/mail.pyc Binary files differnew file mode 100644 index 0000000..71ab53f --- /dev/null +++ b/appengine_django/mail.pyc diff --git a/appengine_django/management/__init__.py b/appengine_django/management/__init__.py new file mode 100755 index 0000000..e69de29 --- /dev/null +++ b/appengine_django/management/__init__.py diff --git a/appengine_django/management/__init__.pyc b/appengine_django/management/__init__.pyc Binary files differnew file mode 100644 index 0000000..e80538d --- /dev/null +++ b/appengine_django/management/__init__.pyc diff --git a/appengine_django/management/commands/__init__.py b/appengine_django/management/commands/__init__.py new file mode 100755 index 0000000..e69de29 --- /dev/null +++ b/appengine_django/management/commands/__init__.py diff --git a/appengine_django/management/commands/__init__.pyc b/appengine_django/management/commands/__init__.pyc Binary files differnew file mode 100644 index 0000000..48a948a --- /dev/null +++ b/appengine_django/management/commands/__init__.pyc diff --git a/appengine_django/management/commands/console.py b/appengine_django/management/commands/console.py new file mode 100755 index 0000000..2c40697 --- /dev/null +++ b/appengine_django/management/commands/console.py @@ -0,0 +1,49 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import code +import getpass +import os +import sys + +from django.conf import settings +from django.core.management.base import BaseCommand + +from google.appengine.ext.remote_api import remote_api_stub + + +def auth_func(): + return raw_input('Username:'), getpass.getpass('Password:') + +class Command(BaseCommand): + """ Start up an interactive console backed by your app using remote_api """ + + help = 'Start up an interactive console backed by your app using remote_api.' + + def run_from_argv(self, argv): + app_id = argv[2] + if len(argv) > 3: + host = argv[3] + else: + host = '%s.appspot.com' % app_id + + remote_api_stub.ConfigureRemoteDatastore(app_id, + '/remote_api', + auth_func, + host) + + code.interact('App Engine interactive console for %s' % (app_id,), + None, + locals()) diff --git a/appengine_django/management/commands/flush.py b/appengine_django/management/commands/flush.py new file mode 100755 index 0000000..c5f3f8c --- /dev/null +++ b/appengine_django/management/commands/flush.py @@ -0,0 +1,36 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +import os +import sys + +from django.core.management.base import BaseCommand + + +class Command(BaseCommand): + """Overrides the default Django flush command. + """ + help = 'Clears the current datastore and loads the initial fixture data.' + + def run_from_argv(self, argv): + from django.db import connection + connection.flush() + from django.core.management import call_command + call_command('loaddata', 'initial_data') + + def handle(self, *args, **kwargs): + self.run_from_argv(None) diff --git a/appengine_django/management/commands/reset.py b/appengine_django/management/commands/reset.py new file mode 100755 index 0000000..126f386 --- /dev/null +++ b/appengine_django/management/commands/reset.py @@ -0,0 +1,32 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import logging +import os +import sys + +from django.core.management.base import BaseCommand + + +class Command(BaseCommand): + """Overrides the default Django reset command. + """ + help = 'Clears the current datastore.' + + def run_from_argv(self, argv): + from django.db import connection + connection.flush() diff --git a/appengine_django/management/commands/rollback.py b/appengine_django/management/commands/rollback.py new file mode 100755 index 0000000..6ce9e4e --- /dev/null +++ b/appengine_django/management/commands/rollback.py @@ -0,0 +1,52 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import sys +import logging + +from django.core.management.base import BaseCommand + + +def run_appcfg(): + # import this so that we run through the checks at the beginning + # and report the appropriate errors + import appcfg + + # We don't really want to use that one though, it just executes this one + from google.appengine.tools import appcfg + + # Reset the logging level to WARN as appcfg will spew tons of logs on INFO + logging.getLogger().setLevel(logging.WARN) + + # Note: if we decide to change the name of this command to something other + # than 'rollback' we will have to munge the args to replace whatever + # we called it with 'rollback' + new_args = sys.argv[:] + new_args.append('.') + appcfg.main(new_args) + + +class Command(BaseCommand): + """Calls the appcfg.py's rollback command for the current project. + + Any additional arguments are passed directly to appcfg.py. + """ + help = 'Calls appcfg.py rollback for the current project.' + args = '[any appcfg.py options]' + + def run_from_argv(self, argv): + run_appcfg() diff --git a/appengine_django/management/commands/runserver.py b/appengine_django/management/commands/runserver.py new file mode 100755 index 0000000..7b91f65 --- /dev/null +++ b/appengine_django/management/commands/runserver.py @@ -0,0 +1,77 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import os +import sys + +from appengine_django.db.base import get_datastore_paths + +from django.core.management.base import BaseCommand + + +def start_dev_appserver(): + """Starts the appengine dev_appserver program for the Django project. + + The appserver is run with default parameters. If you need to pass any special + parameters to the dev_appserver you will have to invoke it manually. + """ + from google.appengine.tools import dev_appserver_main + progname = sys.argv[0] + args = [] + # hack __main__ so --help in dev_appserver_main works OK. + sys.modules['__main__'] = dev_appserver_main + # Set bind ip/port if specified. + if len(sys.argv) > 2: + addrport = sys.argv[2] + try: + addr, port = addrport.split(":") + except ValueError: + addr, port = None, addrport + if not port.isdigit(): + print "Error: '%s' is not a valid port number." % port + sys.exit(1) + else: + addr, port = None, "8000" + if addr: + args.extend(["--address", addr]) + if port: + args.extend(["--port", port]) + # Add email settings + from django.conf import settings + args.extend(['--smtp_host', settings.EMAIL_HOST, + '--smtp_port', str(settings.EMAIL_PORT), + '--smtp_user', settings.EMAIL_HOST_USER, + '--smtp_password', settings.EMAIL_HOST_PASSWORD]) + # Pass the application specific datastore location to the server. + p = get_datastore_paths() + args.extend(["--datastore_path", p[0], "--history_path", p[1]]) + # Append the current working directory to the arguments. + dev_appserver_main.main([progname] + args + [os.getcwdu()]) + + +class Command(BaseCommand): + """Overrides the default Django runserver command. + + Instead of starting the default Django development server this command + fires up a copy of the full fledged appengine dev_appserver that emulates + the live environment your application will be deployed to. + """ + help = 'Runs a copy of the appengine development server.' + args = '[optional port number, or ipaddr:port]' + + def run_from_argv(self, argv): + start_dev_appserver() diff --git a/appengine_django/management/commands/runserver.pyc b/appengine_django/management/commands/runserver.pyc Binary files differnew file mode 100644 index 0000000..38abf92 --- /dev/null +++ b/appengine_django/management/commands/runserver.pyc diff --git a/appengine_django/management/commands/startapp.py b/appengine_django/management/commands/startapp.py new file mode 100644 index 0000000..2648cbd --- /dev/null +++ b/appengine_django/management/commands/startapp.py @@ -0,0 +1,43 @@ +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import os + +import django +from django.core.management.commands import startapp + +import appengine_django + + +class Command(startapp.Command): + def handle_label(self, *args, **kwds): + """Temporary adjust django.__path__ to load app templates from the + helpers directory. + """ + old_path = django.__path__ + django.__path__ = appengine_django.__path__ + startapp.Command.handle_label(self, *args, **kwds) + django.__path__ = old_path + + +class ProjectCommand(Command): + def __init__(self, project_directory): + super(ProjectCommand, self).__init__() + self.project_directory = project_directory + + def handle_label(self, app_name, **options): + super(ProjectCommand, self).handle_label(app_name, self.project_directory, + **options) + diff --git a/appengine_django/management/commands/startapp.pyc b/appengine_django/management/commands/startapp.pyc Binary files differnew file mode 100644 index 0000000..844eb68 --- /dev/null +++ b/appengine_django/management/commands/startapp.pyc diff --git a/appengine_django/management/commands/testserver.py b/appengine_django/management/commands/testserver.py new file mode 100755 index 0000000..bd2c6d1 --- /dev/null +++ b/appengine_django/management/commands/testserver.py @@ -0,0 +1,71 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import os +import sys + +from appengine_django.db.base import destroy_datastore +from appengine_django.db.base import get_test_datastore_paths + +from django.core.management.base import BaseCommand + + +class Command(BaseCommand): + """Overrides the default Django testserver command. + + Instead of starting the default Django development server this command fires + up a copy of the full fledged appengine dev_appserver. + + The appserver is always initialised with a blank datastore with the specified + fixtures loaded into it. + """ + help = 'Runs the development server with data from the given fixtures.' + + def run_from_argv(self, argv): + fixtures = argv[2:] + + # Ensure an on-disk test datastore is used. + from django.db import connection + connection.use_test_datastore = True + connection.test_datastore_inmemory = False + + # Flush any existing test datastore. + connection.flush() + + # Load the fixtures. + from django.core.management import call_command + call_command('loaddata', 'initial_data') + if fixtures: + call_command('loaddata', *fixtures) + + # Build new arguments for dev_appserver. + datastore_path, history_path = get_test_datastore_paths(False) + new_args = argv[0:1] + new_args.extend(['--datastore_path', datastore_path]) + new_args.extend(['--history_path', history_path]) + new_args.extend([os.getcwdu()]) + + # Add email settings + from django.conf import settings + new_args.extend(['--smtp_host', settings.EMAIL_HOST, + '--smtp_port', str(settings.EMAIL_PORT), + '--smtp_user', settings.EMAIL_HOST_USER, + '--smtp_password', settings.EMAIL_HOST_PASSWORD]) + + # Start the test dev_appserver. + from google.appengine.tools import dev_appserver_main + dev_appserver_main.main(new_args) diff --git a/appengine_django/management/commands/update.py b/appengine_django/management/commands/update.py new file mode 100755 index 0000000..e489d5d --- /dev/null +++ b/appengine_django/management/commands/update.py @@ -0,0 +1,51 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import sys +import logging + +from django.core.management.base import BaseCommand + + +def run_appcfg(): + # import this so that we run through the checks at the beginning + # and report the appropriate errors + import appcfg + + # We don't really want to use that one though, it just executes this one + from google.appengine.tools import appcfg + + # Reset the logging level to WARN as appcfg will spew tons of logs on INFO + logging.getLogger().setLevel(logging.WARN) + + # Note: if we decide to change the name of this command to something other + # than 'update' we will have to munge the args to replace whatever + # we called it with 'update' + new_args = sys.argv[:] + new_args.append('.') + appcfg.main(new_args) + +class Command(BaseCommand): + """Calls the appcfg.py's update command for the current project. + + Any additional arguments are passed directly to appcfg.py. + """ + help = 'Calls appcfg.py update for the current project.' + args = '[any appcfg.py options]' + + def run_from_argv(self, argv): + run_appcfg() diff --git a/appengine_django/management/commands/vacuum_indexes.py b/appengine_django/management/commands/vacuum_indexes.py new file mode 100755 index 0000000..ab276b4 --- /dev/null +++ b/appengine_django/management/commands/vacuum_indexes.py @@ -0,0 +1,52 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import sys +import logging + +from django.core.management.base import BaseCommand + + +def run_appcfg(): + # import this so that we run through the checks at the beginning + # and report the appropriate errors + import appcfg + + # We don't really want to use that one though, it just executes this one + from google.appengine.tools import appcfg + + # Reset the logging level to WARN as appcfg will spew tons of logs on INFO + logging.getLogger().setLevel(logging.WARN) + + # Note: if we decide to change the name of this command to something other + # than 'vacuum_indexes' we will have to munge the args to replace whatever + # we called it with 'vacuum_indexes' + new_args = sys.argv[:] + new_args.append('.') + appcfg.main(new_args) + + +class Command(BaseCommand): + """Calls the appcfg.py's vacuum_indexes command for the current project. + + Any additional arguments are passed directly to appcfg.py. + """ + help = 'Calls appcfg.py vacuum_indexes for the current project.' + args = '[any appcfg.py options]' + + def run_from_argv(self, argv): + run_appcfg() diff --git a/appengine_django/models.py b/appengine_django/models.py new file mode 100755 index 0000000..0b9f6dc --- /dev/null +++ b/appengine_django/models.py @@ -0,0 +1,182 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import sys +import types + +from google.appengine.ext import db + +from django import VERSION +from django.core.exceptions import ObjectDoesNotExist +from django.db.models.fields import Field +from django.db.models.options import Options +from django.db.models.loading import register_models, get_model + + +class ModelManager(object): + """Replacement for the default Django model manager.""" + + def __init__(self, owner): + self.owner = owner + + def __getattr__(self, name): + """Pass all attribute requests through to the real model""" + return getattr(self.owner, name) + + +class ModelOptions(object): + """Replacement for the default Django options class. + + This class sits at ._meta of each model. The primary information supplied by + this class that needs to be stubbed out is the list of fields on the model. + """ + + def __init__(self, cls): + self.object_name = cls.__name__ + self.module_name = self.object_name.lower() + model_module = sys.modules[cls.__module__] + self.app_label = model_module.__name__.split('.')[-2] + self.abstract = False + + class pk: + """Stub the primary key to always be 'key_name'""" + name = "key_name" + + def __str__(self): + return "%s.%s" % (self.app_label, self.module_name) + + @property + def many_to_many(self): + """The datastore does not support many to many relationships.""" + return [] + + +class Relation(object): + def __init__(self, to): + self.field_name = "key_name" + + +def PropertyWrapper(prop): + """Wrapper for db.Property to make it look like a Django model Property""" + if isinstance(prop, db.Reference): + prop.rel = Relation(prop.reference_class) + else: + prop.rel = None + prop.serialize = True + return prop + + +class PropertiedClassWithDjango(db.PropertiedClass): + """Metaclass for the combined Django + App Engine model class. + + This metaclass inherits from db.PropertiedClass in the appengine library. + This metaclass has two additional purposes: + 1) Register each model class created with Django (the parent class will take + care of registering it with the appengine libraries). + 2) Add the (minimum number) of attributes and methods to make Django believe + the class is a normal Django model. + + The resulting classes are still not generally useful as Django classes and + are intended to be used by Django only in limited situations such as loading + and dumping fixtures. + """ + + def __new__(cls, name, bases, attrs): + """Creates a combined appengine and Django model. + + The resulting model will be known to both the appengine libraries and + Django. + """ + if name == 'BaseModel': + # This metaclass only acts on subclasses of BaseModel. + return super(PropertiedClassWithDjango, cls).__new__(cls, name, + bases, attrs) + + new_class = super(PropertiedClassWithDjango, cls).__new__(cls, name, + bases, attrs) + + new_class._meta = ModelOptions(new_class) + new_class.objects = ModelManager(new_class) + new_class._default_manager = new_class.objects + new_class.DoesNotExist = types.ClassType('DoesNotExist', + (ObjectDoesNotExist,), {}) + + m = get_model(new_class._meta.app_label, name, False) + if m: + return m + + register_models(new_class._meta.app_label, new_class) + return get_model(new_class._meta.app_label, name, False) + + def __init__(cls, name, bases, attrs): + """Initialises the list of Django properties. + + This method takes care of wrapping the properties created by the superclass + so that they look like Django properties and installing them into the + ._meta object of the class so that Django can find them at the appropriate + time. + """ + super(PropertiedClassWithDjango, cls).__init__(name, bases, attrs) + if name == 'BaseModel': + # This metaclass only acts on subclasses of BaseModel. + return + + fields = [PropertyWrapper(p) for p in cls._properties.values()] + cls._meta.local_fields = fields + + +class BaseModel(db.Model): + """Combined appengine and Django model. + + All models used in the application should derive from this class. + """ + __metaclass__ = PropertiedClassWithDjango + + def __eq__(self, other): + if not isinstance(other, self.__class__): + return False + return self._get_pk_val() == other._get_pk_val() + + def __ne__(self, other): + return not self.__eq__(other) + + def _get_pk_val(self): + """Return the string representation of the model's key""" + return unicode(self.key()) + + def __repr__(self): + """Create a string that can be used to construct an equivalent object. + + e.g. eval(repr(obj)) == obj + """ + # First, creates a dictionary of property names and values. Note that + # property values, not property objects, has to be passed in to constructor. + def _MakeReprTuple(prop_name): + prop = getattr(self.__class__, prop_name) + return (prop_name, prop.get_value_for_datastore(self)) + + d = dict([_MakeReprTuple(prop_name) for prop_name in self.properties()]) + return "%s(**%s)" % (self.__class__.__name__, repr(d)) + + +class RegistrationTestModel(BaseModel): + """Used to check registration with Django is working correctly. + + Django 0.96 only recognises models defined within an applications models + module when get_models() is called so this definition must be here rather + than within the associated test (tests/model_test.py). + """ + pass diff --git a/appengine_django/models.pyc b/appengine_django/models.pyc Binary files differnew file mode 100644 index 0000000..ed6c6ec --- /dev/null +++ b/appengine_django/models.pyc diff --git a/appengine_django/replacement_imp.py b/appengine_django/replacement_imp.py new file mode 100644 index 0000000..330aaf0 --- /dev/null +++ b/appengine_django/replacement_imp.py @@ -0,0 +1,26 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""This file acts as a very minimal replacement for the 'imp' module. + +It contains only what Django expects to use and does not actually implement the +same functionality as the real 'imp' module. +""" + + +def find_module(name, path=None): + """Django needs imp.find_module, but it works fine if nothing is found.""" + raise ImportError diff --git a/appengine_django/replacement_imp.pyc b/appengine_django/replacement_imp.pyc Binary files differnew file mode 100644 index 0000000..c8abe0b --- /dev/null +++ b/appengine_django/replacement_imp.pyc diff --git a/appengine_django/serializer/__init__.py b/appengine_django/serializer/__init__.py new file mode 100755 index 0000000..e69de29 --- /dev/null +++ b/appengine_django/serializer/__init__.py diff --git a/appengine_django/serializer/__init__.pyc b/appengine_django/serializer/__init__.pyc Binary files differnew file mode 100644 index 0000000..8dbe892 --- /dev/null +++ b/appengine_django/serializer/__init__.pyc diff --git a/appengine_django/serializer/python.py b/appengine_django/serializer/python.py new file mode 100755 index 0000000..bce16e7 --- /dev/null +++ b/appengine_django/serializer/python.py @@ -0,0 +1,130 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +A Python "serializer", based on the default Django python serializer. + +The only customisation is in the deserialization process which needs to take +special care to resolve the name and parent attributes of the key for each +entity and also recreate the keys for any references appropriately. +""" + + +from django.conf import settings +from django.core.serializers import base +from django.core.serializers import python +from django.db import models + +from google.appengine.api import datastore_types +from google.appengine.ext import db + +from django.utils.encoding import smart_unicode + +Serializer = python.Serializer + + +class FakeParent(object): + """Fake parent 'model' like object. + + This class exists to allow a parent object to be provided to a new model + without having to load the parent instance itself. + """ + + def __init__(self, parent_key): + self._entity = parent_key + + +def Deserializer(object_list, **options): + """Deserialize simple Python objects back into Model instances. + + It's expected that you pass the Python objects themselves (instead of a + stream or a string) to the constructor + """ + models.get_apps() + for d in object_list: + # Look up the model and starting build a dict of data for it. + Model = python._get_model(d["model"]) + data = {} + key = resolve_key(Model._meta.module_name, d["pk"]) + if key.name(): + data["key_name"] = key.name() + parent = None + if key.parent(): + parent = FakeParent(key.parent()) + m2m_data = {} + + # Handle each field + for (field_name, field_value) in d["fields"].iteritems(): + if isinstance(field_value, str): + field_value = smart_unicode( + field_value, options.get("encoding", + settings.DEFAULT_CHARSET), + strings_only=True) + field = Model.properties()[field_name] + + if isinstance(field, db.Reference): + # Resolve foreign key references. + data[field.name] = resolve_key(Model._meta.module_name, field_value) + if not data[field.name].name(): + raise base.DeserializationError(u"Cannot load Reference with " + "unnamed key: '%s'" % field_value) + else: + data[field.name] = field.validate(field_value) + # Create the new model instance with all it's data, but no parent. + object = Model(**data) + # Now add the parent into the hidden attribute, bypassing the type checks + # in the Model's __init__ routine. + object._parent = parent + # When the deserialized object is saved our replacement DeserializedObject + # class will set object._parent to force the real parent model to be loaded + # the first time it is referenced. + yield base.DeserializedObject(object, m2m_data) + + +def resolve_key(model, key_data): + """Creates a Key instance from a some data. + + Args: + model: The name of the model this key is being resolved for. Only used in + the fourth case below (a plain key_name string). + key_data: The data to create a key instance from. May be in four formats: + * The str() output of a key instance. Eg. A base64 encoded string. + * The repr() output of a key instance. Eg. A string for eval(). + * A list of arguments to pass to db.Key.from_path. + * A single string value, being the key_name of the instance. When this + format is used the resulting key has no parent, and is for the model + named in the model parameter. + + Returns: + An instance of db.Key. If the data cannot be used to create a Key instance + an error will be raised. + """ + if isinstance(key_data, list): + # The key_data is a from_path sequence. + return db.Key.from_path(*key_data) + elif isinstance(key_data, basestring): + if key_data.find("from_path") != -1: + # key_data is encoded in repr(key) format + return eval(key_data) + else: + try: + # key_data encoded a str(key) format + return db.Key(key_data) + except datastore_types.datastore_errors.BadKeyError, e: + # Final try, assume it's a plain key name for the model. + return db.Key.from_path(model, key_data) + else: + raise base.DeserializationError(u"Invalid key data: '%s'" % key_data) diff --git a/appengine_django/serializer/python.pyc b/appengine_django/serializer/python.pyc Binary files differnew file mode 100644 index 0000000..d9a3507 --- /dev/null +++ b/appengine_django/serializer/python.pyc diff --git a/appengine_django/serializer/xml.py b/appengine_django/serializer/xml.py new file mode 100755 index 0000000..f67588a --- /dev/null +++ b/appengine_django/serializer/xml.py @@ -0,0 +1,147 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +""" +Replaces the default Django XML serializer with one that uses the built in +ToXml method for each entity. +""" + +import re + +from django.conf import settings +from django.core.serializers import base +from django.core.serializers import xml_serializer +from django.db import models + +from google.appengine.api import datastore_types +from google.appengine.ext import db + +from python import FakeParent + +getInnerText = xml_serializer.getInnerText + + +class Serializer(xml_serializer.Serializer): + """A Django Serializer class to convert datastore models to XML. + + This class relies on the ToXml method of the entity behind each model to do + the hard work. + """ + + def __init__(self, *args, **kwargs): + super(Serializer, self).__init__(*args, **kwargs) + self._objects = [] + + def handle_field(self, obj, field): + """Fields are not handled individually.""" + pass + + def handle_fk_field(self, obj, field): + """Fields are not handled individually.""" + pass + + def start_object(self, obj): + """Nothing needs to be done to start an object.""" + pass + + def end_object(self, obj): + """Serialize the object to XML and add to the list of objects to output. + + The output of ToXml is manipulated to replace the datastore model name in + the "kind" tag with the Django model name (which includes the Django + application name) to make importing easier. + """ + xml = obj._entity.ToXml() + xml = xml.replace(u"""kind="%s" """ % obj._entity.kind(), + u"""kind="%s" """ % unicode(obj._meta)) + self._objects.append(xml) + + def getvalue(self): + """Wrap the serialized objects with XML headers and return.""" + str = u"""<?xml version="1.0" encoding="utf-8"?>\n""" + str += u"""<django-objects version="1.0">\n""" + str += u"".join(self._objects) + str += u"""</django-objects>""" + return str + + +class Deserializer(xml_serializer.Deserializer): + """A Django Deserializer class to convert XML to Django objects. + + This is a fairly manualy and simplistic XML parser, it supports just enough + functionality to read the keys and fields for an entity from the XML file and + construct a model object. + """ + + def next(self): + """Replacement next method to look for 'entity'. + + The default next implementation exepects 'object' nodes which is not + what the entity's ToXml output provides. + """ + for event, node in self.event_stream: + if event == "START_ELEMENT" and node.nodeName == "entity": + self.event_stream.expandNode(node) + return self._handle_object(node) + raise StopIteration + + def _handle_object(self, node): + """Convert an <entity> node to a DeserializedObject""" + Model = self._get_model_from_node(node, "kind") + data = {} + key = db.Key(node.getAttribute("key")) + if key.name(): + data["key_name"] = key.name() + parent = None + if key.parent(): + parent = FakeParent(key.parent()) + m2m_data = {} + + # Deseralize each field. + for field_node in node.getElementsByTagName("property"): + # If the field is missing the name attribute, bail (are you + # sensing a pattern here?) + field_name = field_node.getAttribute("name") + if not field_name: + raise base.DeserializationError("<field> node is missing the 'name' " + "attribute") + field = Model.properties()[field_name] + field_value = getInnerText(field_node).strip() + + if isinstance(field, db.Reference): + m = re.match("tag:.*\[(.*)\]", field_value) + if not m: + raise base.DeserializationError(u"Invalid reference value: '%s'" % + field_value) + key = m.group(1) + key_obj = db.Key(key) + if not key_obj.name(): + raise base.DeserializationError(u"Cannot load Reference with " + "unnamed key: '%s'" % field_value) + data[field.name] = key_obj + else: + data[field.name] = field.validate(field_value) + + # Create the new model instance with all it's data, but no parent. + object = Model(**data) + # Now add the parent into the hidden attribute, bypassing the type checks + # in the Model's __init__ routine. + object._parent = parent + # When the deserialized object is saved our replacement DeserializedObject + # class will set object._parent to force the real parent model to be loaded + # the first time it is referenced. + return base.DeserializedObject(object, m2m_data) diff --git a/appengine_django/sessions/__init__.py b/appengine_django/sessions/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/appengine_django/sessions/__init__.py diff --git a/appengine_django/sessions/__init__.pyc b/appengine_django/sessions/__init__.pyc Binary files differnew file mode 100644 index 0000000..fd13c38 --- /dev/null +++ b/appengine_django/sessions/__init__.pyc diff --git a/appengine_django/sessions/backends/__init__.py b/appengine_django/sessions/backends/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/appengine_django/sessions/backends/__init__.py diff --git a/appengine_django/sessions/backends/__init__.pyc b/appengine_django/sessions/backends/__init__.pyc Binary files differnew file mode 100644 index 0000000..4242888 --- /dev/null +++ b/appengine_django/sessions/backends/__init__.pyc diff --git a/appengine_django/sessions/backends/db.py b/appengine_django/sessions/backends/db.py new file mode 100644 index 0000000..e2e3aa4 --- /dev/null +++ b/appengine_django/sessions/backends/db.py @@ -0,0 +1,82 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from datetime import datetime + +from django.contrib.sessions.backends import base +from django.core.exceptions import SuspiciousOperation + +from appengine_django.sessions.models import Session + + +class SessionStore(base.SessionBase): + """A key-based session store for Google App Engine.""" + + def load(self): + session = self._get_session(self.session_key) + if session: + try: + return self.decode(session.session_data) + except SuspiciousOperation: + # Create a new session_key for extra security. + pass + self.session_key = self._get_new_session_key() + self._session_cache = {} + self.save() + # Ensure the user is notified via a new cookie. + self.modified = True + return {} + + def save(self, must_create=False): + if must_create and self.exists(self.session_key): + raise base.CreateError + session = Session( + key_name='k:' + self.session_key, + session_data = self.encode(self._session), + expire_date = self.get_expiry_date()) + session.put() + + def exists(self, session_key): + return Session.get_by_key_name('k:' + session_key) is not None + + def delete(self, session_key=None): + if session_key is None: + session_key = self._session_key + session = self._get_session(session_key=session_key) + if session: + session.delete() + + def _get_session(self, session_key): + session = Session.get_by_key_name('k:' + session_key) + if session: + if session.expire_date > datetime.now(): + return session + session.delete() + return None + + def create(self): + while True: + self.session_key = self._get_new_session_key() + try: + # Save immediately to ensure we have a unique entry in the + # database. + self.save(must_create=True) + except base.CreateError: + # Key wasn't unique. Try again. + continue + self.modified = True + self._session_cache = {} + return diff --git a/appengine_django/sessions/backends/db.pyc b/appengine_django/sessions/backends/db.pyc Binary files differnew file mode 100644 index 0000000..934b2f7 --- /dev/null +++ b/appengine_django/sessions/backends/db.pyc diff --git a/appengine_django/sessions/models.py b/appengine_django/sessions/models.py new file mode 100644 index 0000000..1681644 --- /dev/null +++ b/appengine_django/sessions/models.py @@ -0,0 +1,22 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from google.appengine.ext import db + +class Session(db.Model): + """Django compatible App Engine Datastore session model.""" + session_data = db.BlobProperty() + expire_date = db.DateTimeProperty() diff --git a/appengine_django/sessions/models.pyc b/appengine_django/sessions/models.pyc Binary files differnew file mode 100644 index 0000000..8db907d --- /dev/null +++ b/appengine_django/sessions/models.pyc diff --git a/appengine_django/tests/__init__.py b/appengine_django/tests/__init__.py new file mode 100644 index 0000000..b511f58 --- /dev/null +++ b/appengine_django/tests/__init__.py @@ -0,0 +1,56 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Loads all the _test.py files into the top level of the package. + +This file is a hack around the fact that Django expects the tests "module" to +be a single tests.py file and cannot handle a tests package inside an +application. + +All _test.py files inside this package are imported and any classes derived +from unittest.TestCase are then referenced from this file itself so that they +appear at the top level of the tests "module" that Django will import. +""" + + +import os +import re +import types +import unittest + +TEST_RE = r"^.*_test.py$" + +# Search through every file inside this package. +test_names = [] +test_dir = os.path.dirname( __file__) +for filename in os.listdir(test_dir): + if not re.match(TEST_RE, filename): + continue + # Import the test file and find all TestClass clases inside it. + test_module = __import__('appengine_django.tests.%s' % + filename[:-3], {}, {}, + filename[:-3]) + for name in dir(test_module): + item = getattr(test_module, name) + if not (isinstance(item, (type, types.ClassType)) and + issubclass(item, unittest.TestCase)): + continue + # Found a test, bring into the module namespace. + exec "%s = item" % name + test_names.append(name) + +# Hide everything other than the test cases from other modules. +__all__ = test_names diff --git a/appengine_django/tests/commands_test.py b/appengine_django/tests/commands_test.py new file mode 100755 index 0000000..a02ddbf --- /dev/null +++ b/appengine_django/tests/commands_test.py @@ -0,0 +1,183 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Tests that the manage.py commands execute correctly. + +These tests only verify that the commands execute and exit with a success code. +They are intended to catch import exceptions and similar problems, it is left +up to tests in other modules to verify that the functionality of each command +works correctly. +""" + + +import os +import re +import signal +import subprocess +import tempfile +import time +import unittest + +from django.db.models import get_models + +from google.appengine.ext import db +from appengine_django.models import BaseModel +from appengine_django.models import ModelManager +from appengine_django.models import ModelOptions +from appengine_django.models import RegistrationTestModel + + +class CommandsTest(unittest.TestCase): + """Unit tests for the manage.py commands.""" + + # How many seconds to wait for a command to exit. + COMMAND_TIMEOUT = 10 + + def runCommand(self, command, args=None, int_after=None, input=None): + """Helper to run the specified command in a child process. + + Args: + command: The name of the command to run. + args: List of command arguments to run the command with. + int_after: If set to a positive integer, SIGINT will be sent to the + running child process after this many seconds to cause an exit. This + should be less than the COMMAND_TIMEOUT value (10 seconds). + input: A string to write to stdin when the command starts. stdin is + closed after the string is written. + + Returns: + rc: The integer return code of the process. + output: A string containing the childs output. + """ + if not args: + args = [] + start = time.time() + int_sent = False + fd = subprocess.PIPE + + child = subprocess.Popen(["./manage.py", command] + args, stdin=fd, + stdout=fd, stderr=fd, cwd=os.getcwdu()) + if input: + child.stdin.write(input) + child.stdin.close() + + while 1: + rc = child.poll() + if rc is not None: + # Child has exited. + break + elapsed = time.time() - start + if int_after and int_after > 0 and elapsed > int_after and not int_sent: + # Sent SIGINT as requested, give child time to exit cleanly. + os.kill(child.pid, signal.SIGINT) + start = time.time() + int_sent = True + continue + if elapsed < self.COMMAND_TIMEOUT: + continue + # Command is over time, kill and exit loop. + os.kill(child.pid, signal.SIGKILL) + time.sleep(2) # Give time for the signal to be received. + break + + # Return status and output. + return rc, child.stdout.read(), child.stderr.read() + + def assertCommandSucceeds(self, command, *args, **kwargs): + """Asserts that the specified command successfully completes. + + Args: + command: The name of the command to run. + All other arguments are passed directly through to the runCommand + routine. + + Raises: + This function does not return anything but will raise assertion errors if + the command does not exit successfully. + """ + rc, stdout, stderr = self.runCommand(command, *args, **kwargs) + fd, tempname = tempfile.mkstemp() + os.write(fd, stdout) + os.close(fd) + self.assertEquals(0, rc, + "%s did not return successfully (rc: %d): Output in %s" % + (command, rc, tempname)) + os.unlink(tempname) + + def getCommands(self): + """Returns a list of valid commands for manage.py. + + Args: + None + + Returns: + A list of valid commands for manage.py as read from manage.py's help + output. + """ + rc, stdout, stderr = self.runCommand("help") + parts = re.split("Available subcommands:", stderr) + if len(parts) < 2: + return [] + + return [t.strip() for t in parts[-1].split("\n") if t.strip()] + + def testDiffSettings(self): + """Tests the diffsettings command.""" + self.assertCommandSucceeds("diffsettings") + + def testDumpData(self): + """Tests the dumpdata command.""" + self.assertCommandSucceeds("dumpdata") + + def testFlush(self): + """Tests the flush command.""" + self.assertCommandSucceeds("flush") + + def testLoadData(self): + """Tests the loaddata command.""" + self.assertCommandSucceeds("loaddata") + + def testLoadData(self): + """Tests the loaddata command.""" + self.assertCommandSucceeds("loaddata") + + def testReset(self): + """Tests the reste command.""" + self.assertCommandSucceeds("reset", ["appengine_django"]) + + def testRunserver(self): + """Tests the runserver command.""" + self.assertCommandSucceeds("runserver", int_after=2.0) + + def testShell(self): + """Tests the shell command.""" + self.assertCommandSucceeds("shell", input="exit") + + def testUpdate(self): + """Tests that the update command exists. + + Cannot test that it works without mocking out parts of dev_appserver so for + now we just assume that if it is present it will work. + """ + cmd_list = self.getCommands() + self.assert_("update" in cmd_list) + + def testZipCommandListFiltersCorrectly(self): + """When running under a zipfile test that only valid commands are found.""" + cmd_list = self.getCommands() + self.assert_("__init__" not in cmd_list) + self.assert_("base" not in cmd_list) diff --git a/appengine_django/tests/core_test.py b/appengine_django/tests/core_test.py new file mode 100755 index 0000000..7454177 --- /dev/null +++ b/appengine_django/tests/core_test.py @@ -0,0 +1,37 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests that the core module functionality is present and functioning.""" + + +import unittest + +from appengine_django import appid +from appengine_django import have_appserver + + +class AppengineDjangoTest(unittest.TestCase): + """Tests that the helper module has been correctly installed.""" + + def testAppidProvided(self): + """Tests that application ID and configuration has been loaded.""" + self.assert_(appid is not None) + + def testAppserverDetection(self): + """Tests that the appserver detection flag is present and correct.""" + # It seems highly unlikely that these tests would ever be run from within + # an appserver. + self.assertEqual(have_appserver, False) diff --git a/appengine_django/tests/db_test.py b/appengine_django/tests/db_test.py new file mode 100755 index 0000000..452e8f9 --- /dev/null +++ b/appengine_django/tests/db_test.py @@ -0,0 +1,62 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests that the db module correctly initialises the API stubs.""" + + +import unittest + +from django.db import connection +from django.db.backends.appengine.base import DatabaseWrapper + +from appengine_django import appid +from appengine_django.db import base + + +class DatastoreTest(unittest.TestCase): + """Tests that the datastore stubs have been correctly setup.""" + + def testDjangoDBConnection(self): + """Tests that the Django DB connection is using our replacement.""" + self.assert_(isinstance(connection, DatabaseWrapper)) + + def testDjangoDBConnectionStubs(self): + """Tests that members required by Django are stubbed.""" + self.assert_(hasattr(connection, "features")) + self.assert_(hasattr(connection, "ops")) + + def testDjangoDBErrorClasses(self): + """Tests that the error classes required by Django are stubbed.""" + self.assert_(hasattr(base, "DatabaseError")) + self.assert_(hasattr(base, "IntegrityError")) + + def testDatastorePath(self): + """Tests that the datastore path contains the app name.""" + d_path, h_path = base.get_datastore_paths() + self.assertNotEqual(-1, d_path.find("django_%s" % appid)) + self.assertNotEqual(-1, h_path.find("django_%s" % appid)) + + def testTestInMemoryDatastorePath(self): + """Tests that the test datastore is using the in-memory datastore.""" + td_path, th_path = base.get_test_datastore_paths() + self.assert_(td_path is None) + self.assert_(th_path is None) + + def testTestFilesystemDatastorePath(self): + """Tests that the test datastore is on the filesystem when requested.""" + td_path, th_path = base.get_test_datastore_paths(False) + self.assertNotEqual(-1, td_path.find("testdatastore")) + self.assertNotEqual(-1, th_path.find("testdatastore")) diff --git a/appengine_django/tests/memcache_test.py b/appengine_django/tests/memcache_test.py new file mode 100644 index 0000000..4e5f02e --- /dev/null +++ b/appengine_django/tests/memcache_test.py @@ -0,0 +1,43 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Ensures the App Engine memcache API works as Django's memcache backend.""" + +import unittest + +from django.core.cache import get_cache +from appengine_django import appid +from appengine_django import have_appserver + + +class AppengineMemcacheTest(unittest.TestCase): + """Tests that the memcache backend works.""" + + def setUp(self): + """Get the memcache cache module so it is available to tests.""" + self._cache = get_cache("memcached://") + + def testSimpleSetGet(self): + """Tests that a simple set/get operation through the cache works.""" + self._cache.set("test_key", "test_value") + self.assertEqual(self._cache.get("test_key"), "test_value") + + def testDelete(self): + """Tests that delete removes values from the cache.""" + self._cache.set("test_key", "test_value") + self.assertEqual(self._cache.has_key("test_key"), True) + self._cache.delete("test_key") + self.assertEqual(self._cache.has_key("test_key"), False) diff --git a/appengine_django/tests/model_test.py b/appengine_django/tests/model_test.py new file mode 100755 index 0000000..8611d8b --- /dev/null +++ b/appengine_django/tests/model_test.py @@ -0,0 +1,110 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests that the combined appengine and Django models function correctly.""" + + +import unittest + +from django import VERSION +from django.db.models import get_models +from django import forms + +from google.appengine.ext.db import djangoforms +from google.appengine.ext import db + +from appengine_django.models import BaseModel +from appengine_django.models import ModelManager +from appengine_django.models import ModelOptions +from appengine_django.models import RegistrationTestModel + + +class TestModelWithProperties(BaseModel): + """Test model class for checking property -> Django field setup.""" + property1 = db.StringProperty() + property2 = db.IntegerProperty() + property3 = db.Reference() + + +class ModelTest(unittest.TestCase): + """Unit tests for the combined model class.""" + + def testModelRegisteredWithDjango(self): + """Tests that a combined model class has been registered with Django.""" + self.assert_(RegistrationTestModel in get_models()) + + def testDatastoreModelProperties(self): + """Tests that a combined model class still has datastore properties.""" + self.assertEqual(3, len(TestModelWithProperties.properties())) + + def testDjangoModelClass(self): + """Tests the parts of a model required by Django are correctly stubbed.""" + # Django requires model options to be found at ._meta. + self.assert_(isinstance(RegistrationTestModel._meta, ModelOptions)) + # Django requires a manager at .objects + self.assert_(isinstance(RegistrationTestModel.objects, ModelManager)) + # Django requires ._default_manager. + self.assert_(hasattr(RegistrationTestModel, "_default_manager")) + + def testDjangoModelFields(self): + """Tests that a combined model class has (faked) Django fields.""" + fields = TestModelWithProperties._meta.local_fields + self.assertEqual(3, len(fields)) + # Check each fake field has the minimal properties that Django needs. + for field in fields: + # The Django serialization code looks for rel to determine if the field + # is a relationship/reference to another model. + self.assert_(hasattr(field, "rel")) + # serialize is required to tell Django to serialize the field. + self.assertEqual(True, field.serialize) + if field.name == "property3": + # Extra checks for the Reference field. + # rel.field_name is used during serialization to find the field in the + # other model that this field is related to. This should always be + # 'key_name' for appengine models. + self.assertEqual("key_name", field.rel.field_name) + + def testDjangoModelOptionsStub(self): + """Tests that the options stub has the required properties by Django.""" + # Django requires object_name and app_label for serialization output. + self.assertEqual("RegistrationTestModel", + RegistrationTestModel._meta.object_name) + self.assertEqual("appengine_django", RegistrationTestModel._meta.app_label) + # The pk.name member is required during serialization for dealing with + # related fields. + self.assertEqual("key_name", RegistrationTestModel._meta.pk.name) + # The many_to_many method is called by Django in the serialization code to + # find m2m relationships. m2m is not supported by the datastore. + self.assertEqual([], RegistrationTestModel._meta.many_to_many) + + def testDjangoModelManagerStub(self): + """Tests that the manager stub acts as Django would expect.""" + # The serialization code calls model.objects.all() to retrieve all objects + # to serialize. + self.assertEqual([], list(RegistrationTestModel.objects.all())) + + def testDjangoModelPK(self): + """Tests that each model instance has a 'primary key' generated.""" + obj = RegistrationTestModel(key_name="test") + obj.put() + pk = obj._get_pk_val() + self.assert_(pk) + new_obj = RegistrationTestModel.get(pk) + self.assertEqual(obj.key(), new_obj.key()) + + def testModelFormPatched(self): + """Tests that the Django ModelForm is being successfully patched.""" + self.assertEqual(djangoforms.ModelForm, forms.ModelForm) diff --git a/appengine_django/tests/serialization_test.py b/appengine_django/tests/serialization_test.py new file mode 100755 index 0000000..39b92ea --- /dev/null +++ b/appengine_django/tests/serialization_test.py @@ -0,0 +1,310 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests that the serialization modules are functioning correctly. + +In particular, these tests verify that the modifications made to the standard +Django serialization modules function correctly and that the combined datastore +and Django models can be dumped and loaded to all of the provided formats. +""" + + +import os +import re +import unittest +from StringIO import StringIO + +from django.core import serializers + +from google.appengine.ext import db +from appengine_django.models import BaseModel + + +class ModelA(BaseModel): + description = db.StringProperty() + + +class ModelB(BaseModel): + description = db.StringProperty() + friend = db.Reference(ModelA) + + +class TestAllFormats(type): + + def __new__(cls, name, bases, attrs): + """Extends base test functions to be called for every serialisation format. + + Looks for functions matching 'run.*Test', where the wildcard in the middle + matches the desired test name and ensures that a test case is setup to call + that function once for every defined serialisation format. The test case + that is created will be called 'test<format><name>'. Eg, for the function + 'runKeyedObjectTest' functions like 'testJsonKeyedObject' will be created. + """ + test_formats = serializers.get_serializer_formats() + test_formats.remove("python") # Python serializer is only used indirectly. + + for func_name in attrs.keys(): + m = re.match("^run(.*)Test$", func_name) + if not m: + continue + for format in test_formats: + test_name = "test%s%s" % (format.title(), m.group(1)) + test_func = eval("lambda self: getattr(self, \"%s\")(\"%s\")" % + (func_name, format)) + attrs[test_name] = test_func + + return super(TestAllFormats, cls).__new__(cls, name, bases, attrs) + + +class SerializationTest(unittest.TestCase): + """Unit tests for the serialization/deserialization functionality. + + Tests that every loaded serialization format can successfully dump and then + reload objects without the objects changing. + """ + __metaclass__ = TestAllFormats + + def compareObjects(self, orig, new, format="unknown"): + """Compares two objects to ensure they are identical. + + Args: + orig: The original object, must be an instance of db.Model. + new: The new object, must be an instance of db.Model. + format: The serialization format being tested, used to make error output + more helpful. + + Raises: + The function has no return value, but will raise assertion errors if the + objects do not match correctly. + """ + if orig.key().name(): + # Only compare object keys when the key is named. Key IDs are not static + # and will change between dump/load. If you want stable Keys they need to + # be named! + self.assertEqual(orig.key(), new.key(), + "keys not equal after %s serialization: %s != %s" % + (format, repr(orig.key()), repr(new.key()))) + + for key in orig.properties().keys(): + oval = getattr(orig, key) + nval = getattr(new, key) + if isinstance(orig.properties()[key], db.Reference): + # Need to compare object keys not the objects themselves. + oval = oval.key() + nval = nval.key() + self.assertEqual(oval, nval, "%s attribute differs after %s " + "serialization: %s != %s" % (key, format, oval, nval)) + + def doSerialisationTest(self, format, obj, rel_attr=None, obj_ref=None): + """Runs a serialization test on an object for the specified format. + + Args: + format: The name of the Django serialization class to use. + obj: The object to {,de}serialize, must be an instance of db.Model. + rel_attr: Name of the attribute of obj references another model. + obj_ref: The expected object reference, must be an instance of db.Model. + + Raises: + The function has no return value but raises assertion errors if the + object cannot be successfully serialized and then deserialized back to an + identical object. If rel_attr and obj_ref are specified the deserialized + object must also retain the references from the original object. + """ + serialised = serializers.serialize(format, [obj]) + # Try and get the object back from the serialized string. + result = list(serializers.deserialize(format, StringIO(serialised))) + self.assertEqual(1, len(result), + "%s serialization should create 1 object" % format) + result[0].save() # Must save back into the database to get a Key. + self.compareObjects(obj, result[0].object, format) + if rel_attr and obj_ref: + rel = getattr(result[0].object, rel_attr) + if callable(rel): + rel = rel() + self.compareObjects(rel, obj_ref, format) + + def doLookupDeserialisationReferenceTest(self, lookup_dict, format): + """Tests the Key reference is loaded OK for a format. + + Args: + lookup_dict: A dictionary indexed by format containing serialized strings + of the objects to load. + format: The format to extract from the dict and deserialize. + + Raises: + This function has no return value but raises assertion errors if the + string cannot be deserialized correctly or the resulting object does not + reference the object correctly. + """ + if format not in lookup_dict: + # Check not valid for this format. + return + obj = ModelA(description="test object", key_name="test") + obj.put() + s = lookup_dict[format] + result = list(serializers.deserialize(format, StringIO(s))) + self.assertEqual(1, len(result), "expected 1 object from %s" % format) + result[0].save() + self.compareObjects(obj, result[0].object.friend, format) + + def doModelKeyDeserialisationReferenceTest(self, lookup_dict, format): + """Tests a model with a key can be loaded OK for a format. + + Args: + lookup_dict: A dictionary indexed by format containing serialized strings + of the objects to load. + format: The format to extract from the dict and deserialize. + + Returns: + This function has no return value but raises assertion errors if the + string cannot be deserialized correctly or the resulting object is not an + instance of ModelA with a key named 'test'. + """ + if format not in lookup_dict: + # Check not valid for this format. + return + s = lookup_dict[format] + result = list(serializers.deserialize(format, StringIO(s))) + self.assertEqual(1, len(result), "expected 1 object from %s" % format) + result[0].save() + self.assert_(isinstance(result[0].object, ModelA)) + self.assertEqual("test", result[0].object.key().name()) + + # Lookup dicts for the above (doLookupDeserialisationReferenceTest) function. + SERIALIZED_WITH_KEY_AS_LIST = { + "json": """[{"pk": "agR0ZXN0chMLEgZNb2RlbEIiB21vZGVsYmkM", """ + """"model": "tests.modelb", "fields": {"description": "test", """ + """"friend": ["ModelA", "test"] }}]""", + "yaml": """- fields: {description: !!python/unicode 'test', friend: """ + """ [ModelA, test]}\n model: tests.modelb\n pk: """ + """ agR0ZXN0chMLEgZNb2RlbEEiB21vZGVsYWkM\n""" + } + SERIALIZED_WITH_KEY_REPR = { + "json": """[{"pk": "agR0ZXN0chMLEgZNb2RlbEIiB21vZGVsYmkM", """ + """"model": "tests.modelb", "fields": {"description": "test", """ + """"friend": "datastore_types.Key.from_path(""" + """'ModelA', 'test')" }}]""", + "yaml": """- fields: {description: !!python/unicode 'test', friend: """ + """\'datastore_types.Key.from_path("ModelA", "test")\'}\n """ + """model: tests.modelb\n pk: """ + """ agR0ZXN0chMLEgZNb2RlbEEiB21vZGVsYWkM\n""" + } + + # Lookup dict for the doModelKeyDeserialisationReferenceTest function. + MK_SERIALIZED_WITH_LIST = { + "json": """[{"pk": ["ModelA", "test"], "model": "tests.modela", """ + """"fields": {}}]""", + "yaml": """-\n fields: {description: null}\n model: tests.modela\n """ + """pk: [ModelA, test]\n""" + } + MK_SERIALIZED_WITH_KEY_REPR = { + "json": """[{"pk": "datastore_types.Key.from_path('ModelA', 'test')", """ + """"model": "tests.modela", "fields": {}}]""", + "yaml": """-\n fields: {description: null}\n model: tests.modela\n """ + """pk: \'datastore_types.Key.from_path("ModelA", "test")\'\n""" + } + MK_SERIALIZED_WITH_KEY_AS_TEXT = { + "json": """[{"pk": "test", "model": "tests.modela", "fields": {}}]""", + "yaml": """-\n fields: {description: null}\n model: tests.modela\n """ + """pk: test\n""" + } + + # Lookup dict for the function. + SERIALIZED_WITH_NON_EXISTANT_PARENT = { + "json": """[{"pk": "ahhnb29nbGUtYXBwLWVuZ2luZS1kamFuZ29yIgsSBk1vZG""" + """VsQiIGcGFyZW50DAsSBk1vZGVsQSIEdGVzdAw", """ + """"model": "tests.modela", "fields": """ + """{"description": null}}]""", + "yaml": """- fields: {description: null}\n """ + """model: tests.modela\n """ + """pk: ahhnb29nbGUtYXBwLWVuZ2luZS1kamFuZ29yIgsSBk1""" + """vZGVsQiIGcGFyZW50DAsSBk1vZGVsQSIEdGVzdAw\n""", + "xml": """<?xml version="1.0" encoding="utf-8"?>\n""" + """<django-objects version="1.0">\n""" + """<entity kind="tests.modela" key="ahhnb29nbGUtYXBwL""" + """WVuZ2luZS1kamFuZ29yIgsSBk1vZGVsQiIGcGFyZW50DA""" + """sSBk1vZGVsQSIEdGVzdAw">\n """ + """<key>tag:google-app-engine-django.gmail.com,""" + """2008-05-13:ModelA[ahhnb29nbGUtYXBwLWVuZ2luZS1kam""" + """FuZ29yIgsSBk1vZGVsQiIGcGFyZW50DAsSBk1vZGVsQSIEdGVzdAw""" + """]</key>\n <property name="description" """ + """type="null"></property>\n</entity>\n</django-objects>""" + } + + # The following functions are all expanded by the metaclass to be run once + # for every registered Django serialization module. + + def runKeyedObjectTest(self, format): + """Test serialization of a basic object with a named key.""" + obj = ModelA(description="test object", key_name="test") + obj.put() + self.doSerialisationTest(format, obj) + + def runObjectWithIdTest(self, format): + """Test serialization of a basic object with a numeric ID key.""" + obj = ModelA(description="test object") + obj.put() + self.doSerialisationTest(format, obj) + + def runObjectWithReferenceTest(self, format): + """Test serialization of an object that references another object.""" + obj = ModelA(description="test object", key_name="test") + obj.put() + obj2 = ModelB(description="friend object", friend=obj) + obj2.put() + self.doSerialisationTest(format, obj2, "friend", obj) + + def runObjectWithParentTest(self, format): + """Test serialization of an object that has a parent object reference.""" + obj = ModelA(description="parent object", key_name="parent") + obj.put() + obj2 = ModelA(description="child object", key_name="child", parent=obj) + obj2.put() + self.doSerialisationTest(format, obj2, "parent", obj) + + def runObjectWithNonExistantParentTest(self, format): + """Test deserialization of an object referencing a non-existant parent.""" + self.doModelKeyDeserialisationReferenceTest( + self.SERIALIZED_WITH_NON_EXISTANT_PARENT, format) + + def runCreateKeyReferenceFromListTest(self, format): + """Tests that a reference specified as a list in json/yaml can be loaded OK.""" + self.doLookupDeserialisationReferenceTest(self.SERIALIZED_WITH_KEY_AS_LIST, + format) + + def runCreateKeyReferenceFromReprTest(self, format): + """Tests that a reference specified as repr(Key) in can loaded OK.""" + self.doLookupDeserialisationReferenceTest(self.SERIALIZED_WITH_KEY_REPR, + format) + + def runCreateModelKeyFromListTest(self, format): + """Tests that a model key specified as a list can be loaded OK.""" + self.doModelKeyDeserialisationReferenceTest(self.MK_SERIALIZED_WITH_LIST, + format) + + def runCreateModelKeyFromReprTest(self, format): + """Tests that a model key specified as a repr(Key) can be loaded OK.""" + self.doModelKeyDeserialisationReferenceTest( + self.MK_SERIALIZED_WITH_KEY_REPR, format) + + def runCreateModelKeyFromTextTest(self, format): + """Tests that a reference specified as a plain key_name loads OK.""" + self.doModelKeyDeserialisationReferenceTest( + self.MK_SERIALIZED_WITH_KEY_AS_TEXT, format) + + +if __name__ == '__main__': + unittest.main() |