Repository: getsentry/sentry

Issue:
sentry start/upgrade commands don't work if psycopg2 is not installed
Heya,

Sentry installation document says that the suggested db for optimal performance is PostgreSQL but "pip install sentry" does not install "psycopg2" as a dependency and results as a lost of a couple of hours trying to debug why "upgrade/start" subcommand is not there.

It would be good to raise an exception if the database engine is postgresql_psycopg2 but the module is not installed.

Reproduced with 5.4.3 and 5.4.4.



Discussion:
This is because psycopg2 is not a requirement for sentry as a whole. You can run Sentry on MySQL or sqlite (technically). So requiring psycopg2 is making an assumption about how you're going to be deploying your app. psycopg2 is also not a pure python thing, you need libpq-dev. Requiring that for people who use MySQL (and vice-versa with libmysqlclient-dev) is a bit silly.

"It would be good to raise an exception if the database engine is postgresql_psycopg2 but the module is not installed."

I made myself clear in the sentence above. Yeah, requiring postgresql or msyql headers might be silly but giving no clue about why "sentry start" doesn't work is more silly.

I definitely agree with @igungor 

Having dependency check exceptions that clearly state why something has gone wrong is **VERY** helpful to people beginning to use sentry.

It's also trivial to implement, so I'd say it has a lot of merit :+1:.


Resolving patch:
diff --git a/src/sentry/services/http.py b/src/sentry/services/http.py
--- a/src/sentry/services/http.py
+++ b/src/sentry/services/http.py
@@ -7,7 +7,9 @@
 """
 
 from django.core.management import call_command
+
 from sentry.services.base import Service
+from sentry.utils.settings import validate_settings
 
 
 class SentryHTTPServer(Service):
@@ -16,6 +18,8 @@ class SentryHTTPServer(Service):
     def __init__(self, host=None, port=None, debug=False, workers=None):
         from sentry.conf import settings
 
+        validate_settings(settings)
+
         self.host = host or settings.WEB_HOST
         self.port = port or settings.WEB_PORT
         self.workers = workers
diff --git a/src/sentry/utils/imports.py b/src/sentry/utils/imports.py
--- a/src/sentry/utils/imports.py
+++ b/src/sentry/utils/imports.py
@@ -9,6 +9,9 @@
 
 class ModuleProxyCache(dict):
     def __missing__(self, key):
+        if not '.' in key:
+            return __import__(key)
+
         module_name, class_name = key.rsplit('.', 1)
 
         module = __import__(module_name, {}, {}, [class_name], -1)
diff --git a/src/sentry/utils/settings.py b/src/sentry/utils/settings.py
new file mode 100644
--- /dev/null
+++ b/src/sentry/utils/settings.py
@@ -0,0 +1,79 @@
+"""
+sentry.utils.imports
+~~~~~~~~~~~~~~~~~~~~
+
+:copyright: (c) 2010-2013 by the Sentry Team, see AUTHORS for more details.
+:license: BSD, see LICENSE for more details.
+"""
+
+import inspect
+import sys
+
+from sentry.utils.imports import import_string
+
+PACKAGES = {
+    'django.db.backends.postgresql_psycopg2': 'psycopg2.extensions',
+    'django.db.backends.mysql': 'MySQLdb',
+    'django.db.backends.oracle': 'cx_Oracle',
+    'django.core.cache.backends.memcached.MemcachedCache': 'memcache',
+    'django.core.cache.backends.memcached.PyLibMCCache': 'pylibmc'
+}
+
+
+def reraise_as(new_exception_or_type):
+    """
+    Obtained from https://github.com/dcramer/reraise/blob/master/src/reraise.py
+>>> try:
+>>> do_something_crazy()
+>>> except Exception:
+>>> reraise_as(UnhandledException)
+"""
+    __traceback_hide__ = True  # NOQA
+
+    e_type, e_value, e_traceback = sys.exc_info()
+
+    if inspect.isclass(new_exception_or_type):
+        new_type = new_exception_or_type
+        new_exception = new_exception_or_type()
+    else:
+        new_type = type(new_exception_or_type)
+        new_exception = new_exception_or_type
+
+    new_exception.__cause__ = e_value
+
+    try:
+        raise new_type, new_exception, e_traceback
+    finally:
+        del e_traceback
+
+
+def validate_settings(settings):
+    for key, engine_key, engine_type in \
+            [('DATABASES', 'ENGINE', 'database engine'), ('CACHES', 'BACKEND', 'caching backend')]:
+
+        items = settings.get(key, {})
+
+        for item in items.keys():
+            engine = items[item][engine_key]
+            if engine not in PACKAGES:
+                continue
+            validate_dependency(settings, engine_type, engine, PACKAGES[engine])
+
+
+def validate_dependency(settings, dependency_type, dependency, package):
+        try:
+            import_string(package)
+        except ImportError:
+            reraise_as(ConfigurationError(ConfigurationError.get_error_message("%s %s" % (dependency_type, dependency), package)))
+
+
+class ConfigurationError(ValueError):
+    '''
+    This error is thrown whenever a sentry configuration is wrong, or requires a third-party library
+    that's not installed properly or can't be found.
+    '''
+    pass
+
+    @classmethod
+    def get_error_message(self, dependency, package):
+        return """Python could not find %(package)s in your current environment (required by %(dependency)s). If you have it installed, maybe you are using the wrong python binary to run sentry?""" % {"dependency": dependency, "package": package}


Test patch:
diff --git a/tests/sentry/http/tests.py b/tests/sentry/http/tests.py
--- a/tests/sentry/http/tests.py
+++ b/tests/sentry/http/tests.py
@@ -12,6 +12,8 @@
 from raven import Client
 from sentry.models import Group, Event, Project
 from sentry.testutils import TestCase
+from sentry.services.http import SentryHTTPServer
+from sentry.utils.settings import ConfigurationError
 
 
 class RavenIntegrationTest(TestCase):
@@ -152,3 +154,96 @@ def test_signature(self):
         self.assertEquals(instance.server_name, 'not_dcramer.local')
         self.assertEquals(instance.site, 'not_a_real_site')
         self.assertEquals(instance.level, 40)
+
+TEST_DATA = {
+    "postgresql": ('DATABASES', 'psycopg2.extensions', "database engine", "django.db.backends.postgresql_psycopg2", {
+        'default': {
+            'ENGINE': "django.db.backends.postgresql_psycopg2",
+            'NAME': 'test',
+            'USER': 'root',
+            'PASSWORD': '',
+            'HOST': 'localhost',
+            'PORT': ''
+        }
+    }),
+    "mysql": ('DATABASES', 'MySQLdb', "database engine", "django.db.backends.mysql", {
+        'default': {
+            'ENGINE': "django.db.backends.mysql",
+            'NAME': 'test',
+            'USER': 'root',
+            'PASSWORD': '',
+            'HOST': 'localhost',
+            'PORT': ''
+        }
+    }),
+    "oracle": ('DATABASES', 'cx_Oracle', "database engine", "django.db.backends.oracle", {
+        'default': {
+            'ENGINE': "django.db.backends.oracle",
+            'NAME': 'test',
+            'USER': 'root',
+            'PASSWORD': '',
+            'HOST': 'localhost',
+            'PORT': ''
+        }
+    }),
+    "memcache": ('CACHES', 'memcache', "caching backend", "django.core.cache.backends.memcached.MemcachedCache", {
+        'default': {
+            'BACKEND': "django.core.cache.backends.memcached.MemcachedCache",
+            'LOCATION': '127.0.0.1:11211',
+        }
+    }),
+    "pylibmc": ('CACHES', 'pylibmc', "caching backend", "django.core.cache.backends.memcached.PyLibMCCache", {
+        'default': {
+            'BACKEND': "django.core.cache.backends.memcached.PyLibMCCache",
+            'LOCATION': '127.0.0.1:11211',
+        }
+    }),
+}
+
+
+class HttpServiceTest(TestCase):
+    def raise_import_error(self, package):
+        def callable(package_name):
+            if package_name != package:
+                raise RuntimeError("Package being tested differs from expected in test case")
+            msg = "No module named %s" % package
+            raise ImportError(msg)
+        return callable
+
+    def validate_dependency(self, import_string, settings, key, package, dependency_type, dependency, setting_value):
+        import_string.side_effect = self.raise_import_error(package)
+
+        settings.get = lambda setting, default: setting == key and setting_value or {}
+
+        try:
+            SentryHTTPServer()
+        except ConfigurationError, err:
+            self.assertEquals(str(err), "Python could not find %s in your current environment (required by %s %s). If you have it installed, maybe you are using the wrong python binary to run sentry?" % (package, dependency_type, dependency))
+            return
+
+        assert False, "Should not have gotten this far"
+
+    @mock.patch('sentry.conf.settings')
+    @mock.patch('sentry.utils.imports.import_string')
+    def test_validate_fails_on_postgres(self, import_string, settings):
+        self.validate_dependency(import_string, settings, *TEST_DATA['postgresql'])
+
+    @mock.patch('sentry.conf.settings')
+    @mock.patch('sentry.utils.imports.import_string')
+    def test_validate_fails_on_mysql(self, import_string, settings):
+        self.validate_dependency(import_string, settings, *TEST_DATA['mysql'])
+
+    @mock.patch('sentry.conf.settings')
+    @mock.patch('sentry.utils.imports.import_string')
+    def test_validate_fails_on_oracle(self, import_string, settings):
+        self.validate_dependency(import_string, settings, *TEST_DATA['oracle'])
+
+    @mock.patch('sentry.conf.settings')
+    @mock.patch('sentry.utils.imports.import_string')
+    def test_validate_fails_on_memcache(self, import_string, settings):
+        self.validate_dependency(import_string, settings, *TEST_DATA['memcache'])
+
+    @mock.patch('sentry.conf.settings')
+    @mock.patch('sentry.utils.imports.import_string')
+    def test_validate_fails_on_pylibmc(self, import_string, settings):
+        self.validate_dependency(import_string, settings, *TEST_DATA['pylibmc'])