summaryrefslogtreecommitdiffstats
path: root/appengine_django/__init__.py
blob: 629695de7a246ac6b986e1cc048dda9af4cf9191 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
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