diff --git a/README.rst b/README.rst index 03dd3d9..e370a95 100644 --- a/README.rst +++ b/README.rst @@ -14,5 +14,5 @@ Portions of this are from commonware: https://github.com/jsocol/commonware/blob/master/LICENSE -.. |Build Status| image:: https://travis-ci.org/andymckay/django-statsd.svg?branch=master - :target: https://travis-ci.org/andymckay/django-statsd \ No newline at end of file +.. |Build Status| image:: https://travis-ci.org/django-statsd/django-statsd.svg?branch=master + :target: https://travis-ci.org/django-statsd/django-statsd diff --git a/django_statsd/celery.py b/django_statsd/celery.py index b6654af..bcff37a 100644 --- a/django_statsd/celery.py +++ b/django_statsd/celery.py @@ -1,4 +1,4 @@ -from __future__ import absolute_import + from django_statsd.clients import statsd import time diff --git a/django_statsd/clients/__init__.py b/django_statsd/clients/__init__.py index f5e46d0..391fa3c 100644 --- a/django_statsd/clients/__init__.py +++ b/django_statsd/clients/__init__.py @@ -1,6 +1,10 @@ import socket -from django.utils.importlib import import_module +try: + from importlib import import_module +except ImportError: + from django.utils.importlib import import_module + from django.conf import settings _statsd = None @@ -22,7 +26,7 @@ def get_client(): # host = socket.gethostbyaddr(host)[2][0] port = get('STATSD_PORT', 8125) prefix = get('STATSD_PREFIX', None) - return import_module(client).StatsClient(host, port, prefix) + return import_module(client).StatsClient(host=host, port=port, prefix=prefix) if not _statsd: _statsd = get_client() diff --git a/django_statsd/clients/log.py b/django_statsd/clients/log.py index 4bc59c2..2d49f7b 100644 --- a/django_statsd/clients/log.py +++ b/django_statsd/clients/log.py @@ -22,4 +22,5 @@ class StatsClient(StatsClient): def gauge(self, stat, value, rate=1, delta=False): """Set a gauge value.""" - log.info('Gauge: %s, %s%s, %s' % (stat, '' if not delta else 'diff ', value , rate)) + log.info('Gauge: %s, %s%s, %s' % ( + stat, '' if not delta else 'diff ', value, rate)) diff --git a/django_statsd/middleware.py b/django_statsd/middleware.py index 1fe4072..14f9109 100644 --- a/django_statsd/middleware.py +++ b/django_statsd/middleware.py @@ -1,12 +1,20 @@ import inspect import time +from django.conf import settings from django.http import Http404 from django_statsd.clients import statsd -class GraphiteMiddleware(object): +try: + from django.utils.deprecation import MiddlewareMixin +except ImportError: + class MiddlewareMixin(object): + pass + + +class GraphiteMiddleware(MiddlewareMixin): def process_response(self, request, response): statsd.incr('response.%s' % response.status_code) @@ -21,7 +29,7 @@ class GraphiteMiddleware(object): statsd.incr('response.auth.500') -class GraphiteRequestTimingMiddleware(object): +class GraphiteRequestTimingMiddleware(MiddlewareMixin): """statsd's timing data per view.""" def process_view(self, request, view_func, view_args, view_kwargs): @@ -48,8 +56,9 @@ class GraphiteRequestTimingMiddleware(object): data = dict(module=request._view_module, name=request._view_name, method=request.method) statsd.timing('view.{module}.{name}.{method}'.format(**data), ms) - statsd.timing('view.{module}.{method}'.format(**data), ms) - statsd.timing('view.{method}'.format(**data), ms) + if getattr(settings, 'STATSD_VIEW_TIMER_DETAILS', True): + statsd.timing('view.{module}.{method}'.format(**data), ms) + statsd.timing('view.{method}'.format(**data), ms) class TastyPieRequestTimingMiddleware(GraphiteRequestTimingMiddleware): diff --git a/django_statsd/models.py b/django_statsd/models.py index ac5c642..bacf087 100644 --- a/django_statsd/models.py +++ b/django_statsd/models.py @@ -35,6 +35,6 @@ def model_delete(sender, **kwargs): instance._meta.object_name, )) -if getattr(settings, 'STATSD_MODEL_SIGNALS', True): +if getattr(settings, 'STATSD_MODEL_SIGNALS', False): post_save.connect(model_save) post_delete.connect(model_delete) diff --git a/django_statsd/panel.py b/django_statsd/panel.py index fd4de88..ddb64f0 100644 --- a/django_statsd/panel.py +++ b/django_statsd/panel.py @@ -58,7 +58,7 @@ def times_summary(stats): for stat in stats: timings[stat[0].split('|')[0]].append(stat[2]) - for stat, v in timings.iteritems(): + for stat, v in timings.items(): if not v: continue v.sort() diff --git a/django_statsd/patches/__init__.py b/django_statsd/patches/__init__.py index f6df76b..4886f29 100644 --- a/django_statsd/patches/__init__.py +++ b/django_statsd/patches/__init__.py @@ -1,5 +1,9 @@ from django.conf import settings -from django.utils.importlib import import_module + +try: + from importlib import import_module +except ImportError: + from django.utils.importlib import import_module patches = getattr(settings, 'STATSD_PATCHES', []) diff --git a/django_statsd/patches/db.py b/django_statsd/patches/db.py index b0f086a..401d59e 100644 --- a/django_statsd/patches/db.py +++ b/django_statsd/patches/db.py @@ -1,5 +1,8 @@ import django -from django.db.backends import util +try: + from django.db.backends import utils as util +except ImportError: + from django.db.backends import util from django_statsd.patches.utils import wrap, patch_method from django_statsd.clients import statsd @@ -33,6 +36,7 @@ def patched_execute(orig_execute, self, query, *args, **kwargs): with statsd.timer(key(self.db, 'execute.%s' % _get_query_type(query))): return orig_execute(self, query, *args, **kwargs) + def patched_executemany(orig_executemany, self, query, *args, **kwargs): with statsd.timer(key(self.db, 'executemany.%s' % _get_query_type(query))): return orig_executemany(self, query, *args, **kwargs) diff --git a/django_statsd/urls.py b/django_statsd/urls.py index 1a346b1..2476c53 100644 --- a/django_statsd/urls.py +++ b/django_statsd/urls.py @@ -1,10 +1,7 @@ -try: - from django.conf.urls import patterns, url -except ImportError: # django < 1.4 - from django.conf.urls.defaults import patterns, url +from django.conf.urls import url +import django_statsd.views -urlpatterns = patterns( - '', - url('^record$', 'django_statsd.views.record', name='django_statsd.record'), -) +urlpatterns = [ + url('^record$', django_statsd.views.record, name='django_statsd.record'), +] diff --git a/django_statsd/views.py b/django_statsd/views.py index 9c24d79..55372b0 100644 --- a/django_statsd/views.py +++ b/django_statsd/views.py @@ -135,6 +135,7 @@ clients = { @csrf_exempt +@require_http_methods(["GET", "POST"]) def record(request): """ This is a Django method you can link to in your URLs that process @@ -146,10 +147,11 @@ def record(request): you need for imposing security on this method, so that not just anyone can post to it. """ - if 'client' not in request.REQUEST: + data = request.POST or request.GET + if 'client' not in data: return http.HttpResponseBadRequest() - client = request.REQUEST['client'] + client = data.get('client') if client not in clients: return http.HttpResponseBadRequest() diff --git a/docs/index.rst b/docs/index.rst index 1aa4477..00bd41e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -16,6 +16,12 @@ Credits: Changes ------- +0.3.15: + +- push from travis to pypi to keep files clean +- allow less statsd in the middleware +- fix to a specific statsd version + 0.3.14: - pypy testing support @@ -168,6 +174,14 @@ To get timings for your database or your cache, put in some monkeypatches:: 'django_statsd.patches.cache', ] +You can change the host that stats are sent to with the `STATSD_HOST` setting:: + + STATSD_HOST = 'localhost' + +Similarly, you can use the `STATSD_PORT`setting to customize the port number (which defaults to `8125`):: + + STATSD_PORT = 8125 + Toolbar integration ------------------- @@ -250,11 +264,11 @@ First, make sure you can record the timings in your Django site urls. This could be done by pointing straight to the view or including the URL for example:: - from django_statsd.urls import urlpatterns as statsd_patterns + from django_statsd.urls import urlpatterns as statsd_patterns - urlpatterns = patterns('', - ('^services/timing/', include(statsd_patterns)), - ) + urlpatterns = [ + url(r'^services/timing/', include(statsd_patterns)), + ] In this case the URL to the record view will be `/services/timing/record`. @@ -326,6 +340,13 @@ everyone not in INTERNAL_IPS:: STATSD_RECORD_GUARD = internal_only +STATSD_VIEW_TIMER_DETAILS (optional) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The middleware sends timing pings for the almost the same thing three times +when accessing a view: `module.name.method`, `module.method` and `method` by +default. Setting this to `False` just does the former. + Logging errors ~~~~~~~~~~~~~~ @@ -341,9 +362,20 @@ do this by adding in the handler. For example in your logging configuration:: Testing ======= -You can run tests with the following command: +You need to install tox_ to run the tests. +You can run the full test matrix with: - DJANGO_SETTINGS_MODULE='django_statsd.test_settings' nosetests + tox + +or choose a specific environment - let's say Python 3.4 and Django 1.11 - with: + + tox -e py34-django111 + +You can list all the available environments with: + + tox -l + +.. _tox: http://tox.readthedocs.io/en/latest/index.html Nose ==== diff --git a/requirements.txt b/requirements.txt index 13feb01..e8bb3c0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,4 @@ mock nose -unittest2 -statsd>=2.0.0 -django<1.7 +pytest-django +statsd==3.2.1 diff --git a/setup.py b/setup.py index a9eb1a4..ce89191 100644 --- a/setup.py +++ b/setup.py @@ -4,13 +4,13 @@ from setuptools import setup setup( # Because django-statsd was taken, I called this django-statsd-mozilla. name='django-statsd-mozilla', - version='0.3.14', + version='0.3.16', description='Django interface with statsd', long_description=open('README.rst').read(), author='Andy McKay', author_email='andym@mozilla.com', license='BSD', - install_requires=['statsd>=2.0.0'], + install_requires=['statsd >= 2.1.2, != 3.2 , <= 4.0'], packages=['django_statsd', 'django_statsd/patches', 'django_statsd/clients', @@ -29,6 +29,8 @@ setup( 'Intended Audience :: Developers', 'Natural Language :: English', 'Operating System :: OS Independent', - 'Framework :: Django' + 'Framework :: Django', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3' ] ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9869df9 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,34 @@ + +def pytest_configure(): + from django.conf import settings + + settings.configure( + DEBUG_PROPAGATE_EXCEPTIONS=True, + DATABASES={ + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:' + } + }, + SITE_ID=1, + SECRET_KEY='not very secret in tests', + ROOT_URLCONF='django_statsd.urls', + INSTALLED_APPS=( + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.sites', + 'django.contrib.staticfiles', + 'django_statsd', + 'tests', + ), + STATSD_CLIENT='django_statsd.clients.null', + STATSD_PREFIX=None, + METLOG=None, + ) + + try: + import django + django.setup() + except AttributeError: + pass diff --git a/tests/test_django_statsd.py b/tests/test_django_statsd.py new file mode 100644 index 0000000..dc8e5a5 --- /dev/null +++ b/tests/test_django_statsd.py @@ -0,0 +1,546 @@ +import json +import logging.config +import sys +import unittest + +from django.conf import settings +from nose.exc import SkipTest +from nose import tools as nose_tools + +try: + from django.urls import reverse +except ImportError: + from django.core.urlresolvers import reverse +from django.http import HttpResponse, HttpResponseForbidden +from django.test import TestCase +from django.test.client import RequestFactory +from django.utils.http import urlencode + +import mock +from nose.tools import eq_ +from django_statsd.clients import get_client, statsd +from django_statsd.patches import utils +from django_statsd.patches.db import ( + patched_callproc, + patched_execute, + patched_executemany, +) +from django_statsd import middleware + +cfg = { + 'version': 1, + 'formatters': {}, + 'handlers': { + 'test_statsd_handler': { + 'class': 'django_statsd.loggers.errors.StatsdHandler', + }, + }, + 'loggers': { + 'test.logging': { + 'handlers': ['test_statsd_handler'], + }, + }, +} + + +@mock.patch.object(middleware.statsd, 'incr') +class TestIncr(TestCase): + + def setUp(self): + self.req = RequestFactory().get('/') + self.res = HttpResponse() + + def test_graphite_response(self, incr): + gmw = middleware.GraphiteMiddleware() + gmw.process_response(self.req, self.res) + assert incr.called + + def test_graphite_response_authenticated(self, incr): + self.req.user = mock.Mock() + self.req.user.is_authenticated.return_value = True + gmw = middleware.GraphiteMiddleware() + gmw.process_response(self.req, self.res) + eq_(incr.call_count, 2) + + def test_graphite_exception(self, incr): + gmw = middleware.GraphiteMiddleware() + gmw.process_exception(self.req, None) + assert incr.called + + def test_graphite_exception_authenticated(self, incr): + self.req.user = mock.Mock() + self.req.user.is_authenticated.return_value = True + gmw = middleware.GraphiteMiddleware() + gmw.process_exception(self.req, None) + eq_(incr.call_count, 2) + + +@mock.patch.object(middleware.statsd, 'timing') +class TestTiming(unittest.TestCase): + + def setUp(self): + self.req = RequestFactory().get('/') + self.res = HttpResponse() + + def test_request_timing(self, timing): + func = lambda x: x + gmw = middleware.GraphiteRequestTimingMiddleware() + gmw.process_view(self.req, func, tuple(), dict()) + gmw.process_response(self.req, self.res) + eq_(timing.call_count, 3) + names = ['view.%s.%s.GET' % (func.__module__, func.__name__), + 'view.%s.GET' % func.__module__, + 'view.GET'] + for expected, (args, kwargs) in zip(names, timing.call_args_list): + eq_(expected, args[0]) + + def test_request_timing_exception(self, timing): + func = lambda x: x + gmw = middleware.GraphiteRequestTimingMiddleware() + gmw.process_view(self.req, func, tuple(), dict()) + gmw.process_exception(self.req, self.res) + eq_(timing.call_count, 3) + names = ['view.%s.%s.GET' % (func.__module__, func.__name__), + 'view.%s.GET' % func.__module__, + 'view.GET'] + for expected, (args, kwargs) in zip(names, timing.call_args_list): + eq_(expected, args[0]) + + def test_request_timing_tastypie(self, timing): + func = lambda x: x + gmw = middleware.TastyPieRequestTimingMiddleware() + gmw.process_view(self.req, func, tuple(), { + 'api_name': 'my_api_name', + 'resource_name': 'my_resource_name' + }) + gmw.process_response(self.req, self.res) + eq_(timing.call_count, 3) + names = ['view.my_api_name.my_resource_name.GET', + 'view.my_api_name.GET', + 'view.GET'] + for expected, (args, kwargs) in zip(names, timing.call_args_list): + eq_(expected, args[0]) + + def test_request_timing_tastypie_fallback(self, timing): + func = lambda x: x + gmw = middleware.TastyPieRequestTimingMiddleware() + gmw.process_view(self.req, func, tuple(), dict()) + gmw.process_response(self.req, self.res) + eq_(timing.call_count, 3) + names = ['view.%s.%s.GET' % (func.__module__, func.__name__), + 'view.%s.GET' % func.__module__, + 'view.GET'] + for expected, (args, kwargs) in zip(names, timing.call_args_list): + eq_(expected, args[0]) + + +class TestClient(unittest.TestCase): + + @mock.patch.object(settings, 'STATSD_CLIENT', 'statsd.client') + def test_normal(self): + eq_(get_client().__module__, 'statsd.client') + + @mock.patch.object(settings, 'STATSD_CLIENT', + 'django_statsd.clients.null') + def test_null(self): + eq_(get_client().__module__, 'django_statsd.clients.null') + + @mock.patch.object(settings, 'STATSD_CLIENT', + 'django_statsd.clients.toolbar') + def test_toolbar(self): + eq_(get_client().__module__, 'django_statsd.clients.toolbar') + + @mock.patch.object(settings, 'STATSD_CLIENT', + 'django_statsd.clients.toolbar') + def test_toolbar_send(self): + client = get_client() + eq_(client.cache, {}) + client.incr('testing') + eq_(client.cache, {'testing|count': [[1, 1]]}) + + +class TestMetlogClient(TestCase): + + def check_metlog(self): + try: + from metlog.config import client_from_dict_config + return client_from_dict_config + except ImportError: + raise SkipTest("Metlog is not installed") + + @nose_tools.raises(AttributeError) + def test_no_metlog(self): + with self.settings(STATSD_PREFIX='moz_metlog', + STATSD_CLIENT='django_statsd.clients.moz_metlog'): + get_client() + + def _create_client(self): + client_from_dict_config = self.check_metlog() + + # Need to load within the test in case metlog is not installed + from metlog.config import client_from_dict_config + + METLOG_CONF = { + 'logger': 'django-statsd', + 'sender': { + 'class': 'metlog.senders.DebugCaptureSender', + }, + } + + return client_from_dict_config(METLOG_CONF) + + def test_get_client(self): + metlog = self._create_client() + with self.settings(METLOG=metlog, + STATSD_PREFIX='moz_metlog', + STATSD_CLIENT='django_statsd.clients.moz_metlog'): + client = get_client() + eq_(client.__module__, 'django_statsd.clients.moz_metlog') + + def test_metlog_incr(self): + metlog = self._create_client() + with self.settings(METLOG=metlog, + STATSD_PREFIX='moz_metlog', + STATSD_CLIENT='django_statsd.clients.moz_metlog'): + client = get_client() + eq_(len(client.metlog.sender.msgs), 0) + client.incr('testing') + eq_(len(client.metlog.sender.msgs), 1) + + msg = json.loads(client.metlog.sender.msgs[0]) + eq_(msg['severity'], 6) + eq_(msg['payload'], '1') + eq_(msg['fields']['rate'], 1) + eq_(msg['fields']['name'], 'moz_metlog.testing') + eq_(msg['type'], 'counter') + + def test_metlog_decr(self): + metlog = self._create_client() + with self.settings(METLOG=metlog, + STATSD_PREFIX='moz_metlog', + STATSD_CLIENT='django_statsd.clients.moz_metlog'): + client = get_client() + eq_(len(client.metlog.sender.msgs), 0) + client.decr('testing') + eq_(len(client.metlog.sender.msgs), 1) + + msg = json.loads(client.metlog.sender.msgs[0]) + eq_(msg['severity'], 6) + eq_(msg['payload'], '-1') + eq_(msg['fields']['rate'], 1) + eq_(msg['fields']['name'], 'moz_metlog.testing') + eq_(msg['type'], 'counter') + + def test_metlog_timing(self): + metlog = self._create_client() + with self.settings(METLOG=metlog, + STATSD_PREFIX='moz_metlog', + STATSD_CLIENT='django_statsd.clients.moz_metlog'): + client = get_client() + eq_(len(client.metlog.sender.msgs), 0) + client.timing('testing', 512, rate=2) + eq_(len(client.metlog.sender.msgs), 1) + + msg = json.loads(client.metlog.sender.msgs[0]) + eq_(msg['severity'], 6) + eq_(msg['payload'], '512') + eq_(msg['fields']['rate'], 2) + eq_(msg['fields']['name'], 'moz_metlog.testing') + eq_(msg['type'], 'timer') + + @nose_tools.raises(AttributeError) + def test_metlog_no_prefixes(self): + metlog = self._create_client() + + with self.settings(METLOG=metlog, + STATSD_CLIENT='django_statsd.clients.moz_metlog'): + client = get_client() + client.incr('foo', 2) + + def test_metlog_prefixes(self): + metlog = self._create_client() + + with self.settings(METLOG=metlog, + STATSD_PREFIX='some_prefix', + STATSD_CLIENT='django_statsd.clients.moz_metlog'): + client = get_client() + eq_(len(client.metlog.sender.msgs), 0) + + client.timing('testing', 512, rate=2) + client.incr('foo', 2) + client.decr('bar', 5) + + eq_(len(client.metlog.sender.msgs), 3) + + msg = json.loads(client.metlog.sender.msgs[0]) + eq_(msg['severity'], 6) + eq_(msg['payload'], '512') + eq_(msg['fields']['rate'], 2) + eq_(msg['fields']['name'], 'some_prefix.testing') + eq_(msg['type'], 'timer') + + msg = json.loads(client.metlog.sender.msgs[1]) + eq_(msg['severity'], 6) + eq_(msg['payload'], '2') + eq_(msg['fields']['rate'], 1) + eq_(msg['fields']['name'], 'some_prefix.foo') + eq_(msg['type'], 'counter') + + msg = json.loads(client.metlog.sender.msgs[2]) + eq_(msg['severity'], 6) + eq_(msg['payload'], '-5') + eq_(msg['fields']['rate'], 1) + eq_(msg['fields']['name'], 'some_prefix.bar') + eq_(msg['type'], 'counter') + + +# This is primarily for Zamboni, which loads in the custom middleware +# classes, one of which, breaks posts to our url. Let's stop that. +@mock.patch.object(settings, 'MIDDLEWARE_CLASSES', []) +class TestRecord(TestCase): + + urls = 'django_statsd.urls' + + def setUp(self): + super(TestRecord, self).setUp() + self.url = reverse('django_statsd.record') + settings.STATSD_RECORD_GUARD = None + self.good = {'client': 'boomerang', 'nt_nav_st': 1, + 'nt_domcomp': 3} + self.stick = {'client': 'stick', + 'window.performance.timing.domComplete': 123, + 'window.performance.timing.domInteractive': 456, + 'window.performance.timing.domLoading': 789, + 'window.performance.timing.navigationStart': 0, + 'window.performance.navigation.redirectCount': 3, + 'window.performance.navigation.type': 1} + + def test_no_client(self): + response = self.client.get(self.url) + assert response.status_code == 400 + + def test_no_valid_client(self): + response = self.client.get(self.url, {'client': 'no'}) + assert response.status_code == 400 + + def test_boomerang_almost(self): + response = self.client.get(self.url, {'client': 'boomerang'}) + assert response.status_code == 400 + + def test_boomerang_minimum(self): + content = self.client.get( + self.url, { + 'client': 'boomerang', + 'nt_nav_st': 1, + }).content.decode() + assert content == 'recorded' + + @mock.patch('django_statsd.views.process_key') + def test_boomerang_something(self, process_key): + content = self.client.get(self.url, self.good).content.decode() + assert content == 'recorded' + assert process_key.called + + def test_boomerang_post(self): + assert self.client.post(self.url + '?' + urlencode(self.good), self.good).status_code == 405 + + def test_good_guard(self): + settings.STATSD_RECORD_GUARD = lambda r: None + response = self.client.get(self.url, self.good) + assert response.status_code == 200 + + def test_bad_guard(self): + settings.STATSD_RECORD_GUARD = lambda r: HttpResponseForbidden() + response = self.client.get(self.url, self.good) + assert response.status_code == 403 + + def test_stick_get(self): + assert self.client.get(self.url, self.stick).status_code == 405 + + @mock.patch('django_statsd.views.process_key') + def test_stick(self, process_key): + assert self.client.post(self.url, self.stick).status_code == 200 + assert process_key.called + + def test_stick_start(self): + data = self.stick.copy() + del data['window.performance.timing.navigationStart'] + assert self.client.post(self.url, data).status_code == 400 + + @mock.patch('django_statsd.views.process_key') + def test_stick_missing(self, process_key): + data = self.stick.copy() + del data['window.performance.timing.domInteractive'] + assert self.client.post(self.url, data).status_code == 200 + assert process_key.called + + def test_stick_garbage(self): + data = self.stick.copy() + data['window.performance.timing.domInteractive'] = '' + assert self.client.post(self.url, data).status_code == 400 + + def test_stick_some_garbage(self): + data = self.stick.copy() + data['window.performance.navigation.redirectCount'] = '' + assert self.client.post(self.url, data).status_code == 400 + + def test_stick_more_garbage(self): + data = self.stick.copy() + data['window.performance.navigation.type'] = '' + assert self.client.post(self.url, data).status_code == 400 + + +@mock.patch.object(middleware.statsd, 'incr') +class TestErrorLog(TestCase): + + def setUp(self): + logging.config.dictConfig(cfg) + self.log = logging.getLogger('test.logging') + + def division_error(self): + try: + 1 / 0 + except: + return sys.exc_info() + + def test_emit(self, incr): + self.log.error('blargh!', exc_info=self.division_error()) + assert incr.call_args[0][0] == 'error.zerodivisionerror' + + def test_not_emit(self, incr): + self.log.error('blargh!') + assert not incr.called + + +class TestPatchMethod(TestCase): + + def setUp(self): + super(TestPatchMethod, self).setUp() + + class DummyClass(object): + + def sumargs(self, a, b, c=3, d=4): + return a + b + c + d + + def badfn(self, a, b=2): + raise ValueError + + self.cls = DummyClass + + def test_late_patching(self): + """ + Objects created before patching should get patched as well. + """ + def patch_fn(original_fn, self, *args, **kwargs): + return original_fn(self, *args, **kwargs) + 10 + + obj = self.cls() + self.assertEqual(obj.sumargs(1, 2, 3, 4), 10) + utils.patch_method(self.cls, 'sumargs')(patch_fn) + self.assertEqual(obj.sumargs(1, 2, 3, 4), 20) + + def test_doesnt_call_original_implicitly(self): + """ + Original fn must be called explicitly from patched to be + executed. + """ + def patch_fn(original_fn, self, *args, **kwargs): + return 10 + + with self.assertRaises(ValueError): + obj = self.cls() + obj.badfn(1, 2) + + utils.patch_method(self.cls, 'badfn')(patch_fn) + self.assertEqual(obj.badfn(1, 2), 10) + + def test_args_kwargs_are_honored(self): + """ + Args and kwargs must be honored between calls from the patched to + the original version. + """ + def patch_fn(original_fn, self, *args, **kwargs): + return original_fn(self, *args, **kwargs) + + utils.patch_method(self.cls, 'sumargs')(patch_fn) + obj = self.cls() + self.assertEqual(obj.sumargs(1, 2), 10) + self.assertEqual(obj.sumargs(1, 1, d=1), 6) + self.assertEqual(obj.sumargs(1, 1, 1, 1), 4) + + def test_patched_fn_can_receive_arbitrary_arguments(self): + """ + Args and kwargs can be received arbitrarily with no contraints on + the patched fn, even if the original_fn had a fixed set of + allowed args and kwargs. + """ + def patch_fn(original_fn, self, *args, **kwargs): + return args, kwargs + + utils.patch_method(self.cls, 'badfn')(patch_fn) + obj = self.cls() + self.assertEqual(obj.badfn(1, d=2), ((1,), {'d': 2})) + self.assertEqual(obj.badfn(1, d=2), ((1,), {'d': 2})) + self.assertEqual(obj.badfn(1, 2, c=1, d=2), ((1, 2), {'c': 1, 'd': 2})) + + +class TestCursorWrapperPatching(TestCase): + example_queries = { + 'select': 'select * from something;', + 'insert': 'insert (1, 2) into something;', + 'update': 'update something set a=1;', + } + + def test_patched_callproc_calls_timer(self): + for operation, query in list(self.example_queries.items()): + with mock.patch.object(statsd, 'timer') as timer: + client = mock.Mock(executable_name='client_executable_name') + db = mock.Mock(executable_name='name', alias='alias', client=client) + instance = mock.Mock(db=db) + + patched_callproc(lambda *args, **kwargs: None, instance, query) + + self.assertEqual(timer.call_count, 1) + self.assertEqual(timer.call_args[0][0], 'db.client_executable_name.alias.callproc.%s' % operation) + + def test_patched_execute_calls_timer(self): + for operation, query in list(self.example_queries.items()): + with mock.patch.object(statsd, 'timer') as timer: + client = mock.Mock(executable_name='client_executable_name') + db = mock.Mock(executable_name='name', alias='alias', client=client) + instance = mock.Mock(db=db) + + patched_execute(lambda *args, **kwargs: None, instance, query) + + self.assertEqual(timer.call_count, 1) + self.assertEqual(timer.call_args[0][0], 'db.client_executable_name.alias.execute.%s' % operation) + + def test_patched_executemany_calls_timer(self): + for operation, query in list(self.example_queries.items()): + with mock.patch.object(statsd, 'timer') as timer: + client = mock.Mock(executable_name='client_executable_name') + db = mock.Mock(executable_name='name', alias='alias', client=client) + instance = mock.Mock(db=db) + + patched_executemany(lambda *args, **kwargs: None, instance, query) + + self.assertEqual(timer.call_count, 1) + self.assertEqual(timer.call_args[0][0], 'db.client_executable_name.alias.executemany.%s' % operation) + + @mock.patch('django_statsd.patches.db.patched_callproc') + @mock.patch('django_statsd.patches.db.patched_executemany') + @mock.patch('django_statsd.patches.db.patched_execute') + @mock.patch('django.db.backends.utils.CursorWrapper') + def test_cursorwrapper_patching(self, CursorWrapper, execute, executemany, callproc): + from django_statsd.patches.db import patch + execute.__name__ = 'execute' + executemany.__name__ = 'executemany' + callproc.__name__ = 'callproc' + execute.return_value = 'execute' + executemany.return_value = 'executemany' + callproc.return_value = 'callproc' + patch() + + self.assertEqual(CursorWrapper.execute(), 'execute') + self.assertEqual(CursorWrapper.executemany(), 'executemany') + self.assertEqual(CursorWrapper.callproc(), 'callproc') diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..fdb8cdf --- /dev/null +++ b/tox.ini @@ -0,0 +1,32 @@ +[pytest] +addopts=--tb=short + +[tox] +envlist = + {py27,py33,py34,py35,pypy,pypy3}-django18, + {py27,py34,py35,pypy,pypy3}-django{19,110}, + {py27,py34,py35,py36,pypy,pypy3}-django111, + {py35,py36,pypy,pypy3}-djangomaster + +[travis:env] +DJANGO = + 1.8: django18 + 1.9: django19 + 1.10: django110 + 1.11: django111 + master: djangomaster + +[testenv] +commands = py.test +setenv = + PYTHONDONTWRITEBYTECODE=1 + PYTHONWARNINGS=once + +deps = + py27: -roptional.txt + django18: Django>=1.8,<1.9 + django19: Django>=1.9,<1.10 + django110: Django>=1.10,<1.11 + django111: Django>=1.11,<2.0 + djangomaster: https://github.com/django/django/archive/master.tar.gz + -rrequirements.txt