django-statsd 3.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,32 @@
1
+ """django-statsd: submit Django query and view durations to statsd."""
2
+
3
+ from importlib import metadata
4
+
5
+ from django_statsd import celery, json, redis, templates
6
+ from django_statsd.middleware import (
7
+ decorator,
8
+ decr,
9
+ incr,
10
+ named_wrapper,
11
+ start,
12
+ stop,
13
+ with_,
14
+ wrapper,
15
+ )
16
+
17
+ __version__: str = metadata.version('django-statsd')
18
+
19
+ __all__ = [
20
+ 'celery',
21
+ 'decorator',
22
+ 'decr',
23
+ 'incr',
24
+ 'json',
25
+ 'named_wrapper',
26
+ 'redis',
27
+ 'start',
28
+ 'stop',
29
+ 'templates',
30
+ 'with_',
31
+ 'wrapper',
32
+ ]
@@ -0,0 +1,54 @@
1
+ """Celery signal integration submitting task counters and timings."""
2
+
3
+ from collections.abc import Callable
4
+ from typing import Any
5
+
6
+ from django_statsd import middleware, utils
7
+
8
+ try:
9
+ from celery.utils import dispatch
10
+
11
+ from celery import signals
12
+ except ImportError: # pragma: no cover
13
+ # celery ships no type information (see [[tool.mypy.overrides]]),
14
+ # so `signals`/`dispatch` are already typed `Any` for mypy and
15
+ # reassigning `None` here needs no ignore there. ty resolves the
16
+ # real `celery.utils.dispatch` module instead, so that one needs
17
+ # its own suppression.
18
+ signals = None
19
+ dispatch = None # ty: ignore[invalid-assignment]
20
+
21
+
22
+ if signals is not None and dispatch is not None:
23
+ counter = utils.get_counter('celery.status')
24
+
25
+ def _make_increment(signal_name: str) -> Callable[..., None]:
26
+ def _increment(**kwargs: Any) -> None:
27
+ counter.increment(signal_name)
28
+
29
+ return _increment
30
+
31
+ for _signal_name in dir(signals):
32
+ _instance = getattr(signals, _signal_name)
33
+ if isinstance(_instance, dispatch.Signal):
34
+ # weak=False: the receiver is a closure that would otherwise
35
+ # be garbage collected immediately and never fire.
36
+ _instance.connect(_make_increment(_signal_name), weak=False)
37
+
38
+ def start(**kwargs: Any) -> None:
39
+ """Open a scope when a task starts."""
40
+ middleware.StatsdMiddleware.start('celery')
41
+
42
+ def stop(task: Any = None, **kwargs: Any) -> None:
43
+ """Submit the task's metrics and close the scope."""
44
+ if task is not None:
45
+ middleware.StatsdMiddleware.stop(task.name)
46
+ middleware.StatsdMiddleware.scope.timings = None
47
+
48
+ def clear(**kwargs: Any) -> None:
49
+ """Drop the scope, so a failed task leaves no timer running."""
50
+ middleware.StatsdMiddleware.scope.timings = None
51
+
52
+ signals.task_prerun.connect(start)
53
+ signals.task_postrun.connect(stop)
54
+ signals.task_failure.connect(clear)
@@ -0,0 +1,33 @@
1
+ """Database query timing through Django's ``execute_wrapper`` hooks.
2
+
3
+ Enabled by the ``STATSD_TRACK_DATABASE`` setting;
4
+ :class:`~django_statsd.middleware.StatsdMiddleware` wraps every configured
5
+ connection for the duration of each request and submits the query
6
+ durations as ``sql.<alias>`` timings.
7
+ """
8
+
9
+ from collections.abc import Callable
10
+ from typing import Any
11
+
12
+ QueryContext = dict[str, Any]
13
+ ExecuteFunc = Callable[[str, Any, bool, QueryContext], Any]
14
+ ExecuteWrapper = Callable[[ExecuteFunc, str, Any, bool, QueryContext], Any]
15
+
16
+
17
+ def statsd_execute_wrapper(alias: str) -> ExecuteWrapper:
18
+ """Build an execute wrapper timing queries as ``sql.<alias>``."""
19
+ # Imported here to avoid a circular import: middleware imports this
20
+ # module when installing the wrappers.
21
+ from django_statsd import middleware
22
+
23
+ def timed_execute(
24
+ execute: ExecuteFunc,
25
+ sql: str,
26
+ params: Any,
27
+ many: bool,
28
+ context: QueryContext,
29
+ ) -> Any:
30
+ with middleware.with_(f'sql.{alias}'):
31
+ return execute(sql, params, many, context)
32
+
33
+ return timed_execute
django_statsd/json.py ADDED
@@ -0,0 +1,21 @@
1
+ # The module is intentionally named `json` to mirror the patched
2
+ # library (like `celery.py`/`redis.py`/`templates.py`); it is public
3
+ # API (`django_statsd.json`, see `__init__.__all__` and
4
+ # `docs/django_statsd.rst`), so renaming it would be a breaking change.
5
+ # ruff: noqa: A005
6
+ """Time stdlib :mod:`json` calls as ``json.<function>`` metrics."""
7
+
8
+ import json
9
+
10
+ from django_statsd import middleware
11
+
12
+ if not hasattr(json, 'statsd_patched'):
13
+ # Monkeypatching the stdlib json module: none of the checkers know
14
+ # about `statsd_patched`, and ty additionally resolves `json.load`
15
+ # et al. to their concrete stdlib signatures, so a wrapped callable
16
+ # is not assignable there either.
17
+ json.statsd_patched = True # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
18
+ json.load = middleware.wrapper('json', json.load) # ty: ignore[invalid-assignment]
19
+ json.loads = middleware.wrapper('json', json.loads) # ty: ignore[invalid-assignment]
20
+ json.dump = middleware.wrapper('json', json.dump) # ty: ignore[invalid-assignment]
21
+ json.dumps = middleware.wrapper('json', json.dumps) # ty: ignore[invalid-assignment]
@@ -0,0 +1,581 @@
1
+ """Statsd middleware tracking view, middleware and database timings."""
2
+
3
+ import collections
4
+ import functools
5
+ import logging
6
+ import re
7
+ import time
8
+ import warnings
9
+ from collections.abc import Callable
10
+ from contextlib import ExitStack
11
+ from types import TracebackType
12
+ from typing import Any, ClassVar, ParamSpec, TypeVar
13
+
14
+ import statsd
15
+ from asgiref.local import Local
16
+ from django.db import connections
17
+ from django.http import HttpRequest
18
+ from django.http.response import HttpResponseBase
19
+
20
+ from django_statsd import settings, utils
21
+
22
+ logger: logging.Logger = logging.getLogger(__name__)
23
+
24
+ P = ParamSpec('P')
25
+ T = TypeVar('T')
26
+
27
+ GetResponse = Callable[[HttpRequest], HttpResponseBase]
28
+
29
+ TAGS_LIKE_SUPPORTED: tuple[str, ...] = ('=', '_is_')
30
+
31
+
32
+ def _get_tags_like() -> str | None:
33
+ """Validate ``STATSD_TAGS_LIKE`` into a separator or ``None``."""
34
+ tags_like = settings.STATSD_TAGS_LIKE
35
+ if tags_like is None:
36
+ return None
37
+ if tags_like is True:
38
+ return '_is_'
39
+ if tags_like in TAGS_LIKE_SUPPORTED:
40
+ return str(tags_like)
41
+
42
+ warnings.warn(
43
+ 'Unsupported `STATSD_TAGS_LIKE` setting. '
44
+ f'Please, choose from {TAGS_LIKE_SUPPORTED!r}',
45
+ stacklevel=2,
46
+ )
47
+ return None
48
+
49
+
50
+ MAKE_TAGS_LIKE: str | None = _get_tags_like()
51
+
52
+
53
+ def is_ajax(request: HttpRequest) -> bool:
54
+ """Recreate the old Django ``is_ajax`` check (jQuery-style ajax)."""
55
+ return request.headers.get('x-requested-with') == 'XMLHttpRequest'
56
+
57
+
58
+ class WithTimer:
59
+ """Context manager returned by calling a :class:`Timer`."""
60
+
61
+ def __init__(self, timer: 'Timer', key: str) -> None:
62
+ """Bind the timer and the key this block will be recorded under."""
63
+ self.timer = timer
64
+ self.key = key
65
+
66
+ def __enter__(self) -> None:
67
+ """Start the timer."""
68
+ self.timer.start(self.key)
69
+
70
+ def __exit__(
71
+ self,
72
+ type_: type[BaseException] | None,
73
+ value: BaseException | None,
74
+ traceback: TracebackType | None,
75
+ ) -> None:
76
+ """Stop the timer, however the block was left."""
77
+ self.timer.stop(self.key)
78
+
79
+
80
+ class Client:
81
+ """Base for the scope's metric holders.
82
+
83
+ Collects values during a request and submits them in one go, so a
84
+ request produces one burst of packets instead of a trickle.
85
+ """
86
+
87
+ class_: ClassVar[type[Any]] = statsd.Client
88
+
89
+ def __init__(self, prefix: str = 'view') -> None:
90
+ """Build the prefix, nesting it under ``STATSD_PREFIX``."""
91
+ if settings.STATSD_PREFIX:
92
+ prefix = f'{settings.STATSD_PREFIX}.{prefix}'
93
+ self.prefix: str = prefix
94
+
95
+ def get_client(self, *args: str | None) -> Any:
96
+ """Build a python-statsd client for this prefix plus `args`."""
97
+ prefix = '.'.join(a for a in (self.prefix, *args) if a)
98
+ return utils.get_client(prefix, class_=self.class_)
99
+
100
+ def submit(self, *args: str | None) -> None:
101
+ """Send everything collected. Subclasses define what that means.
102
+
103
+ Raises:
104
+ NotImplementedError: always, on the base class.
105
+
106
+ """
107
+ raise NotImplementedError('Subclasses must define a `submit` function')
108
+
109
+
110
+ class Counter(Client):
111
+ """Counters accumulated over one request or task."""
112
+
113
+ class_: ClassVar[type[Any]] = statsd.Counter
114
+
115
+ def __init__(self, prefix: str = 'view') -> None:
116
+ """Start with every counter at zero."""
117
+ super().__init__(prefix)
118
+ self.data: collections.defaultdict[str, int] = collections.defaultdict(
119
+ int
120
+ )
121
+
122
+ def increment(self, key: str, delta: int = 1) -> None:
123
+ """Add `delta` to `key`."""
124
+ self.data[key] += delta
125
+
126
+ def decrement(self, key: str, delta: int = 1) -> None:
127
+ """Subtract `delta` from `key`."""
128
+ self.data[key] -= delta
129
+
130
+ def submit(self, *args: str | None) -> None:
131
+ """Send every counter that moved. Zeroes are not worth a packet."""
132
+ client = self.get_client(*args)
133
+ for key, value in self.data.items():
134
+ if value:
135
+ client.increment(key, value)
136
+
137
+
138
+ class Timer(Client):
139
+ """Timings accumulated over one request or task.
140
+
141
+ A key may be started more than once before it is stopped, so the
142
+ starts are kept on a stack and the durations add up.
143
+ """
144
+
145
+ class_: ClassVar[type[Any]] = statsd.Timer
146
+
147
+ def __init__(self, prefix: str = 'view') -> None:
148
+ """Start with no timers running and nothing recorded."""
149
+ super().__init__(prefix)
150
+ self.starts: collections.defaultdict[str, collections.deque[float]] = (
151
+ collections.defaultdict(collections.deque)
152
+ )
153
+ self.data: collections.defaultdict[str, float] = (
154
+ collections.defaultdict(float)
155
+ )
156
+
157
+ def start(self, key: str) -> None:
158
+ """Start timing `key`."""
159
+ self.starts[key].append(time.time())
160
+
161
+ def stop(self, key: str) -> float:
162
+ """Stop timing `key` and add the elapsed time to its total.
163
+
164
+ Args:
165
+ key: The name passed to a matching :meth:`start`.
166
+
167
+ Returns:
168
+ The seconds that elapsed since that `start`.
169
+
170
+ Raises:
171
+ AssertionError: if `key` was never started.
172
+
173
+ """
174
+ assert self.starts[key], (
175
+ f'Unable to stop tracking {key}, never started tracking it'
176
+ )
177
+
178
+ delta = time.time() - self.starts[key].pop()
179
+ # Clean up when we're done
180
+ if not self.starts[key]:
181
+ del self.starts[key]
182
+
183
+ self.data[key] += delta
184
+ return delta
185
+
186
+ def submit(self, *args: str | None) -> None:
187
+ """Send every recorded timing and clear them.
188
+
189
+ Raises:
190
+ AssertionError: under ``STATSD_DEBUG``, if a timer was
191
+ started and never stopped.
192
+
193
+ """
194
+ client = self.get_client(*args)
195
+ for key in list(self.data.keys()):
196
+ client.send(key, self.data.pop(key))
197
+
198
+ if settings.STATSD_DEBUG:
199
+ assert not self.starts, (
200
+ f'Timer(s) {dict(self.starts)!r} were started but '
201
+ 'never stopped'
202
+ )
203
+
204
+ def __call__(self, key: str) -> WithTimer:
205
+ """Return a context manager timing `key`."""
206
+ return WithTimer(self, key)
207
+
208
+
209
+ class StatsdMiddleware:
210
+ """Opens the scope and submits the per-view metrics.
211
+
212
+ Goes at the top of ``MIDDLEWARE``, with
213
+ :class:`StatsdMiddlewareTimer` at the bottom. The scope lives on
214
+ :class:`asgiref.local.Local`, so each request gets its own under
215
+ both WSGI and ASGI.
216
+ """
217
+
218
+ scope: ClassVar[Local] = Local()
219
+
220
+ def __init__(self, get_response: GetResponse) -> None:
221
+ """Store the next callable in the middleware chain."""
222
+ self.get_response = get_response
223
+
224
+ def __call__(self, request: HttpRequest) -> HttpResponseBase:
225
+ """Time one request, wrapping the database if asked to."""
226
+ # Store the timings in the request so it can be used everywhere
227
+ self.process_request(request)
228
+ try:
229
+ with ExitStack() as stack:
230
+ if settings.STATSD_TRACK_DATABASE:
231
+ # Imported here to avoid a circular import at load
232
+ # time: database.py needs this module's helpers.
233
+ from django_statsd import database
234
+
235
+ for alias in connections:
236
+ stack.enter_context(
237
+ connections[alias].execute_wrapper(
238
+ database.statsd_execute_wrapper(alias)
239
+ )
240
+ )
241
+ response = self.get_response(request)
242
+ return self.process_response(request, response)
243
+ finally:
244
+ self.cleanup(request)
245
+
246
+ @classmethod
247
+ def _scope_get(cls, name: str) -> Any:
248
+ return getattr(cls.scope, name, None)
249
+
250
+ @classmethod
251
+ def skip_view(cls, view_name: str) -> bool:
252
+ """Whether `view_name` matches ``STATSD_VIEWS_TO_SKIP``."""
253
+ for pattern in settings.STATSD_VIEWS_TO_SKIP:
254
+ if re.match(pattern, view_name):
255
+ logger.debug('Skipping metric `%s`', view_name)
256
+ return True
257
+
258
+ return False
259
+
260
+ @classmethod
261
+ def start(cls, prefix: str = 'view') -> Local:
262
+ """Open a scope and start the total timer.
263
+
264
+ Args:
265
+ prefix: The metric prefix, ``view`` for requests and
266
+ ``celery`` for tasks.
267
+
268
+ Returns:
269
+ The scope, which the middleware puts on ``request.statsd``.
270
+
271
+ """
272
+ cls.scope.timings = Timer(prefix)
273
+ cls.scope.timings.start('total')
274
+ cls.scope.counter = Counter(prefix)
275
+ cls.scope.counter.increment('hit')
276
+ cls.scope.counter_codes = Counter(prefix)
277
+ cls.scope.counter_codes.increment('hit')
278
+ cls.scope.counter_site = Counter(prefix)
279
+ cls.scope.counter_site.increment('hit')
280
+ cls.scope.view_name = None
281
+ return cls.scope
282
+
283
+ @classmethod
284
+ def stop(cls, *key: str) -> None:
285
+ """Stop the total timer and submit everything collected."""
286
+ timings: Timer | None = cls._scope_get('timings')
287
+ if not timings:
288
+ return
289
+
290
+ timings.stop('total')
291
+ timings.submit(*key)
292
+ counter: Counter | None = cls._scope_get('counter')
293
+ if counter:
294
+ counter.submit(*key)
295
+ counter_site: Counter | None = cls._scope_get('counter_site')
296
+ if counter_site:
297
+ counter_site.submit('site')
298
+
299
+ def process_request(self, request: HttpRequest) -> None:
300
+ """Open the scope and hang it off the request."""
301
+ # request.statsd is a documented dynamic attribute (see
302
+ # docs/django_statsd.rst) set by this middleware; neither
303
+ # django-stubs nor ty know about it.
304
+ request.statsd = self.start() # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
305
+ if settings.STATSD_TRACK_MIDDLEWARE:
306
+ self.scope.timings.start('process_request')
307
+
308
+ def process_view(
309
+ self,
310
+ request: HttpRequest,
311
+ view_func: Callable[..., HttpResponseBase],
312
+ view_args: tuple[Any, ...],
313
+ view_kwargs: dict[str, Any],
314
+ ) -> None:
315
+ """Name the metric after the view Django resolved."""
316
+ timings: Timer | None = self._scope_get('timings')
317
+ if settings.STATSD_TRACK_MIDDLEWARE and timings:
318
+ timings.start('process_view')
319
+
320
+ # View name is defined as module.view
321
+ # (e.g. django.contrib.auth.views.login)
322
+ view_name = view_func.__module__
323
+
324
+ # CBV and callable-instance specific
325
+ if hasattr(view_func, '__name__'):
326
+ view_name = f'{view_name}.{view_func.__name__}'
327
+ else:
328
+ view_name = f'{view_name}.{view_func.__class__.__name__}'
329
+
330
+ if MAKE_TAGS_LIKE:
331
+ view_name = view_name.replace('.', '_')
332
+ view_name = f'view{MAKE_TAGS_LIKE}{view_name}'
333
+
334
+ self.scope.view_name = view_name
335
+
336
+ def process_response(
337
+ self,
338
+ request: HttpRequest,
339
+ response: HttpResponseBase,
340
+ ) -> HttpResponseBase:
341
+ """Count the status class and submit the request's metrics."""
342
+ view_name: str | None = self._scope_get('view_name')
343
+ if view_name and self.skip_view(view_name):
344
+ return response
345
+
346
+ counter_codes: Counter | None = self._scope_get('counter_codes')
347
+ if counter_codes:
348
+ counter_codes.increment(f'{response.status_code // 100}xx')
349
+ counter_codes.submit('http_codes')
350
+
351
+ timings: Timer | None = self._scope_get('timings')
352
+ if settings.STATSD_TRACK_MIDDLEWARE and timings:
353
+ timings.stop('process_response')
354
+
355
+ method = (request.method or 'get').lower()
356
+ if MAKE_TAGS_LIKE:
357
+ tag_method = f'method{MAKE_TAGS_LIKE}{method.replace(".", "_")}'
358
+ ajax = f'is_ajax_{MAKE_TAGS_LIKE}{str(is_ajax(request)).lower()}'
359
+ if view_name:
360
+ self.stop(tag_method, view_name, ajax)
361
+ else:
362
+ if is_ajax(request):
363
+ method += '_ajax'
364
+ if view_name:
365
+ self.stop(method, view_name)
366
+
367
+ self.cleanup(request)
368
+ return response
369
+
370
+ def process_exception(
371
+ self,
372
+ request: HttpRequest,
373
+ exception: BaseException,
374
+ ) -> None:
375
+ """Stop the timer and count the failure as a 5xx."""
376
+ timings: Timer | None = self._scope_get('timings')
377
+ if settings.STATSD_TRACK_MIDDLEWARE and timings:
378
+ timings.stop('process_exception')
379
+
380
+ counter_codes: Counter | None = self._scope_get('counter_codes')
381
+ if counter_codes:
382
+ counter_codes.increment('5xx')
383
+ counter_codes.submit('http_codes')
384
+
385
+ def process_template_response(
386
+ self,
387
+ request: HttpRequest,
388
+ response: HttpResponseBase,
389
+ ) -> HttpResponseBase:
390
+ """Stop the template timer."""
391
+ timings: Timer | None = self._scope_get('timings')
392
+ if settings.STATSD_TRACK_MIDDLEWARE and timings:
393
+ timings.stop('process_template_response')
394
+ return response
395
+
396
+ def cleanup(self, request: HttpRequest) -> None:
397
+ """Clear the scope so the next request starts fresh."""
398
+ self.scope.timings = None
399
+ self.scope.counter = None
400
+ self.scope.counter_codes = None
401
+ self.scope.counter_site = None
402
+ self.scope.view_name = None
403
+ request.statsd = None # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
404
+
405
+
406
+ class StatsdMiddlewareTimer:
407
+ """Closes the timers :class:`StatsdMiddleware` opened.
408
+
409
+ Goes at the bottom of ``MIDDLEWARE``. Between the pair they time
410
+ every middleware you install in between.
411
+ """
412
+
413
+ def __init__(self, get_response: GetResponse) -> None:
414
+ """Store the next callable in the middleware chain."""
415
+ self.get_response = get_response
416
+
417
+ def __call__(self, request: HttpRequest) -> HttpResponseBase:
418
+ """Time the inner half of the stack."""
419
+ self.process_request(request)
420
+ return self.process_response(request, self.get_response(request))
421
+
422
+ @staticmethod
423
+ def _timings() -> Timer | None:
424
+ if settings.STATSD_TRACK_MIDDLEWARE:
425
+ return getattr(StatsdMiddleware.scope, 'timings', None)
426
+ return None
427
+
428
+ def process_request(self, request: HttpRequest) -> None:
429
+ """Stop the inbound timer the tracker started."""
430
+ timings = self._timings()
431
+ if timings:
432
+ timings.stop('process_request')
433
+
434
+ def process_view(
435
+ self,
436
+ request: HttpRequest,
437
+ view_func: Callable[..., HttpResponseBase],
438
+ view_args: tuple[Any, ...],
439
+ view_kwargs: dict[str, Any],
440
+ ) -> None:
441
+ """Stop the view timer."""
442
+ timings = self._timings()
443
+ if timings:
444
+ timings.stop('process_view')
445
+
446
+ def process_response(
447
+ self,
448
+ request: HttpRequest,
449
+ response: HttpResponseBase,
450
+ ) -> HttpResponseBase:
451
+ """Start the outbound timer the tracker will stop."""
452
+ timings = self._timings()
453
+ if timings:
454
+ timings.start('process_response')
455
+ return response
456
+
457
+ def process_exception(
458
+ self,
459
+ request: HttpRequest,
460
+ exception: BaseException,
461
+ ) -> None:
462
+ """Start the exception timer the tracker will stop."""
463
+ timings = self._timings()
464
+ if timings:
465
+ timings.start('process_exception')
466
+
467
+ def process_template_response(
468
+ self,
469
+ request: HttpRequest,
470
+ response: HttpResponseBase,
471
+ ) -> HttpResponseBase:
472
+ """Start the template timer the tracker will stop."""
473
+ timings = self._timings()
474
+ if timings:
475
+ timings.start('process_template_response')
476
+ return response
477
+
478
+
479
+ class DummyWith:
480
+ """Stands in for :class:`WithTimer` outside a tracked request.
481
+
482
+ Lets the module-level helpers be used anywhere without the caller
483
+ checking whether a request is being timed.
484
+ """
485
+
486
+ def __enter__(self) -> None:
487
+ """Do nothing."""
488
+
489
+ def __exit__(
490
+ self,
491
+ type_: type[BaseException] | None,
492
+ value: BaseException | None,
493
+ traceback: TracebackType | None,
494
+ ) -> None:
495
+ """Do nothing."""
496
+
497
+
498
+ def _timings() -> Timer | None:
499
+ return getattr(StatsdMiddleware.scope, 'timings', None)
500
+
501
+
502
+ def _counter() -> Counter | None:
503
+ return getattr(StatsdMiddleware.scope, 'counter', None)
504
+
505
+
506
+ def start(key: str) -> None:
507
+ """Start timing `key` in the current scope, if there is one."""
508
+ timings = _timings()
509
+ if timings:
510
+ timings.start(key)
511
+
512
+
513
+ def stop(key: str) -> float | None:
514
+ """Stop timing `key`.
515
+
516
+ Returns:
517
+ The elapsed seconds, or ``None`` outside a tracked request.
518
+
519
+ """
520
+ timings = _timings()
521
+ if timings:
522
+ return timings.stop(key)
523
+ return None
524
+
525
+
526
+ def with_(key: str) -> WithTimer | DummyWith:
527
+ """Return a context manager timing `key`.
528
+
529
+ Returns:
530
+ A timer inside a tracked request, and a no-op outside one.
531
+
532
+ """
533
+ timings = _timings()
534
+ if timings:
535
+ return timings(key)
536
+ return DummyWith()
537
+
538
+
539
+ def incr(key: str, value: int = 1) -> None:
540
+ """Add `value` to the counter `key`, if a scope is open."""
541
+ counter = _counter()
542
+ if counter:
543
+ counter.increment(key, value)
544
+
545
+
546
+ def decr(key: str, value: int = 1) -> None:
547
+ """Subtract `value` from the counter `key`, if a scope is open."""
548
+ counter = _counter()
549
+ if counter:
550
+ counter.decrement(key, value)
551
+
552
+
553
+ def wrapper(prefix: str, f: Callable[P, T]) -> Callable[P, T]:
554
+ """Wrap `f` so each call is timed as ``<prefix>.<function name>``."""
555
+ # Not every Callable exposes __name__ (e.g. functools.partial or a
556
+ # callable instance), so fall back to the type name instead of
557
+ # assuming a plain function.
558
+ name = getattr(f, '__name__', type(f).__name__)
559
+
560
+ @functools.wraps(f)
561
+ def _wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
562
+ with with_(f'{prefix}.{name.lower()}'):
563
+ return f(*args, **kwargs)
564
+
565
+ return _wrapper
566
+
567
+
568
+ def named_wrapper(name: str, f: Callable[P, T]) -> Callable[P, T]:
569
+ """Wrap `f` so each call is timed as `name`."""
570
+
571
+ @functools.wraps(f)
572
+ def _wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
573
+ with with_(name):
574
+ return f(*args, **kwargs)
575
+
576
+ return _wrapper
577
+
578
+
579
+ def decorator(prefix: str) -> Callable[[Callable[P, T]], Callable[P, T]]:
580
+ """Return a decorator timing each call under `prefix`."""
581
+ return functools.partial(wrapper, prefix)
django_statsd/py.typed ADDED
File without changes
django_statsd/redis.py ADDED
@@ -0,0 +1,47 @@
1
+ """Patch :class:`redis.Redis` to time commands as ``redis.<command>``."""
2
+
3
+ from collections.abc import Callable
4
+ from typing import Any, cast
5
+
6
+ from django_statsd import middleware
7
+
8
+ try:
9
+ import redis
10
+ except ImportError: # pragma: no cover
11
+ redis = None # type: ignore[assignment]
12
+
13
+
14
+ if (
15
+ # mypy infers `redis` as non-Optional here because the `except
16
+ # ImportError: redis = None` assignment above is itself a type
17
+ # error (silenced via ignore[assignment]) and so does not widen
18
+ # the declared type; the guard is still required at runtime for
19
+ # environments where the optional `redis` dependency is missing.
20
+ redis is not None # type: ignore[redundant-expr]
21
+ and not getattr(redis.Redis, 'statsd_patched', False)
22
+ ):
23
+ # The pre-patch class, kept as a handle for tests and for anything
24
+ # that needs to reach past the patch. The class below subclasses
25
+ # `redis.Redis` by name so its base stays statically resolvable.
26
+ _original_redis = redis.Redis
27
+
28
+ class StatsdRedis(redis.Redis):
29
+ """A :class:`redis.Redis` that times every command it runs."""
30
+
31
+ statsd_patched = True
32
+
33
+ def execute_command(self, *args: Any, **kwargs: Any) -> Any:
34
+ """Run a command, timed as ``redis.<command>``."""
35
+ name = str(args[0]).lower() if args else 'unknown'
36
+ with middleware.with_(f'redis.{name}'):
37
+ # redis ships py.typed but leaves execute_command
38
+ # unannotated. Casting keeps the unknown from leaking
39
+ # into this method's return value.
40
+ # mypy already reads it as Callable, hence the
41
+ # redundant-cast suppression.
42
+ call = cast( # type: ignore[redundant-cast]
43
+ 'Callable[..., Any]', super().execute_command
44
+ )
45
+ return call(*args, **kwargs)
46
+
47
+ redis.Redis = StatsdRedis # type: ignore[misc] # ty: ignore[invalid-assignment]
@@ -0,0 +1,62 @@
1
+ """Django settings wrappers with safe defaults for django-statsd."""
2
+
3
+ from typing import Any
4
+
5
+ from django.conf import settings
6
+ from django.core import exceptions
7
+
8
+
9
+ def get_setting(key: str, default: Any = None) -> Any:
10
+ """Read `key` from Django's settings.
11
+
12
+ Returns `default` when Django is not configured yet, so importing
13
+ this module never raises.
14
+
15
+ """
16
+ try:
17
+ return getattr(settings, key, default)
18
+ except exceptions.ImproperlyConfigured:
19
+ return default
20
+
21
+
22
+ #: Enable tracking all requests using the middleware
23
+ STATSD_TRACK_MIDDLEWARE = get_setting('STATSD_TRACK_MIDDLEWARE', False)
24
+
25
+ #: Set the global statsd prefix if needed. Otherwise use the root
26
+ STATSD_PREFIX = get_setting('STATSD_PREFIX')
27
+
28
+ #: Enable warnings such as timers which are started but not finished.
29
+ #: Defaults to ``DEBUG`` if not configured
30
+ STATSD_DEBUG = get_setting('STATSD_DEBUG', get_setting('DEBUG'))
31
+
32
+ #: Statsd disabled mode, avoids sending metrics to the real server.
33
+ #: Useful for debugging purposes.
34
+ STATSD_DISABLED = get_setting('STATSD_DISABLED', False)
35
+
36
+ #: Enable creating tags as well as the bare version. This causes an ajax
37
+ #: view to be stored both as the regular view name and as the ajax tag.
38
+ #: Supported separators are ``_is_`` and ``=``
39
+ STATSD_TAGS_LIKE = get_setting('STATSD_TAGS_LIKE')
40
+
41
+ #: Statsd host, defaults to 127.0.0.1
42
+ STATSD_HOST = get_setting('STATSD_HOST', '127.0.0.1')
43
+
44
+ #: Statsd port, defaults to 8125
45
+ STATSD_PORT = get_setting('STATSD_PORT', 8125)
46
+
47
+ #: Statsd sample rate, lowering this decreases the (random) odds of
48
+ #: actually submitting the data. Between 0 and 1 where 1 means always
49
+ STATSD_SAMPLE_RATE = get_setting('STATSD_SAMPLE_RATE', 1.0)
50
+
51
+ #: List of regular expressions of views to skip
52
+ STATSD_VIEWS_TO_SKIP = get_setting(
53
+ 'STATSD_VIEWS_TO_SKIP',
54
+ [
55
+ r'django.contrib.admin',
56
+ ],
57
+ )
58
+
59
+ #: Track database query timings via ``connection.execute_wrapper``. The
60
+ #: middleware wraps every configured connection for the duration of each
61
+ #: request and submits ``sql.<alias>`` timings.
62
+ STATSD_TRACK_DATABASE = get_setting('STATSD_TRACK_DATABASE', False)
@@ -0,0 +1,15 @@
1
+ """Time Django template rendering as ``render_django`` metrics."""
2
+
3
+ from django.template import loader
4
+
5
+ from django_statsd import middleware
6
+
7
+ if not hasattr(loader, 'statsd_patched'):
8
+ # Monkeypatching django.template.loader: none of the checkers know
9
+ # about `statsd_patched`, and ty additionally resolves
10
+ # `render_to_string` to its concrete signature, so the wrapped
11
+ # callable is not assignable there either.
12
+ loader.statsd_patched = True # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
13
+ loader.render_to_string = middleware.named_wrapper( # ty: ignore[invalid-assignment]
14
+ 'render_django', loader.render_to_string
15
+ )
django_statsd/utils.py ADDED
@@ -0,0 +1,51 @@
1
+ """Helpers to build python-statsd connections and clients."""
2
+
3
+ from typing import Any
4
+
5
+ import statsd
6
+
7
+ from django_statsd import settings
8
+
9
+
10
+ def get_connection(
11
+ host: str | None = None,
12
+ port: int | None = None,
13
+ sample_rate: float | None = None,
14
+ disabled: bool | None = None,
15
+ ) -> Any:
16
+ """Build a python-statsd connection, defaulting to the settings."""
17
+ if not host:
18
+ host = settings.STATSD_HOST
19
+
20
+ if not port:
21
+ port = settings.STATSD_PORT
22
+
23
+ if not sample_rate:
24
+ sample_rate = settings.STATSD_SAMPLE_RATE
25
+
26
+ if not disabled:
27
+ disabled = settings.STATSD_DISABLED
28
+
29
+ return statsd.Connection(host, port, sample_rate, disabled)
30
+
31
+
32
+ def get_client(
33
+ name: str,
34
+ connection: Any = None,
35
+ class_: type[Any] = statsd.Client,
36
+ ) -> Any:
37
+ """Build a python-statsd client of `class_` named `name`."""
38
+ if not connection:
39
+ connection = get_connection()
40
+
41
+ return class_(name, connection)
42
+
43
+
44
+ def get_timer(name: str, connection: Any = None) -> Any:
45
+ """Build a :class:`statsd.Timer` named `name`."""
46
+ return get_client(name, connection, statsd.Timer)
47
+
48
+
49
+ def get_counter(name: str, connection: Any = None) -> Any:
50
+ """Build a :class:`statsd.Counter` named `name`."""
51
+ return get_client(name, connection, statsd.Counter)
@@ -0,0 +1,243 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-statsd
3
+ Version: 3.0.0
4
+ Summary: Django app that submits query and view durations to statsd.
5
+ Author: Rick van Hattem
6
+ Author-email: Rick van Hattem <Wolph@Wol.ph>
7
+ License-Expression: BSD-3-Clause
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 5 - Production/Stable
10
+ Classifier: Environment :: Web Environment
11
+ Classifier: Framework :: Django
12
+ Classifier: Framework :: Django :: 5.2
13
+ Classifier: Framework :: Django :: 6.0
14
+ Classifier: Framework :: Django :: 6.1
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3 :: Only
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Programming Language :: Python :: 3.14
25
+ Classifier: Topic :: Internet :: WWW/HTTP
26
+ Classifier: Typing :: Typed
27
+ Requires-Dist: asgiref>=3.6
28
+ Requires-Dist: django>=5.2
29
+ Requires-Dist: python-statsd>=2.1
30
+ Requires-Dist: matplotlib>=3.8 ; extra == 'assets'
31
+ Requires-Dist: pillow>=10.0 ; extra == 'assets'
32
+ Requires-Dist: playwright>=1.40 ; extra == 'assets'
33
+ Requires-Dist: django-statsd[tests] ; extra == 'dev'
34
+ Requires-Dist: ruff>=0.15.0 ; extra == 'dev'
35
+ Requires-Dist: mypy>=1.14 ; extra == 'dev'
36
+ Requires-Dist: django-stubs[compatible-mypy]>=5.1 ; extra == 'dev'
37
+ Requires-Dist: basedpyright>=1.0 ; extra == 'dev'
38
+ Requires-Dist: pyrefly>=0.1 ; extra == 'dev'
39
+ Requires-Dist: ty>=0.0.1 ; extra == 'dev'
40
+ Requires-Dist: sphinx>=8.0 ; extra == 'docs'
41
+ Requires-Dist: furo>=2025.1 ; extra == 'docs'
42
+ Requires-Dist: sphinxcontrib-mermaid>=1.0 ; extra == 'docs'
43
+ Requires-Dist: pytest>=8.0 ; extra == 'tests'
44
+ Requires-Dist: pytest-django>=4.8 ; extra == 'tests'
45
+ Requires-Dist: pytest-cov>=6.0 ; extra == 'tests'
46
+ Requires-Dist: coverage>=7.0 ; extra == 'tests'
47
+ Requires-Dist: celery>=5.3 ; extra == 'tests'
48
+ Requires-Dist: redis>=5.0 ; extra == 'tests'
49
+ Requires-Dist: tox>=4.0 ; extra == 'tox'
50
+ Requires-Dist: tox-uv>=1.0 ; extra == 'tox'
51
+ Requires-Dist: tox-gh-actions>=3.0 ; extra == 'tox'
52
+ Requires-Python: >=3.10
53
+ Project-URL: Homepage, https://github.com/WoLpH/django-statsd/
54
+ Project-URL: Documentation, https://django-stats.readthedocs.io/
55
+ Project-URL: Changelog, https://github.com/WoLpH/django-statsd/blob/master/CHANGELOG.md
56
+ Provides-Extra: assets
57
+ Provides-Extra: dev
58
+ Provides-Extra: docs
59
+ Provides-Extra: tests
60
+ Provides-Extra: tox
61
+ Description-Content-Type: text/markdown
62
+
63
+ <p align="center">
64
+ <img src="https://raw.githubusercontent.com/WoLpH/django-statsd/master/docs/images/logo.png" alt="django-statsd" width="420">
65
+ </p>
66
+
67
+ <p align="center">
68
+ <a href="https://github.com/WoLpH/django-statsd/actions/workflows/ci.yml"><img src="https://github.com/WoLpH/django-statsd/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
69
+ <a href="https://pypi.org/project/django-statsd/"><img src="https://img.shields.io/pypi/v/django-statsd" alt="PyPI"></a>
70
+ <a href="https://pypi.org/project/django-statsd/"><img src="https://img.shields.io/pypi/pyversions/django-statsd" alt="Python"></a>
71
+ <a href="https://pypi.org/project/django-statsd/"><img src="https://img.shields.io/pypi/dm/django-statsd" alt="Downloads"></a>
72
+ <a href="https://django-stats.readthedocs.io/en/latest/"><img src="https://readthedocs.org/projects/django-stats/badge/?version=latest" alt="Documentation"></a>
73
+ <a href="https://github.com/WoLpH/django-statsd/blob/master/LICENSE"><img src="https://img.shields.io/pypi/l/django-statsd" alt="License"></a>
74
+ </p>
75
+
76
+ Two middleware entries, and every view, query, template and Celery task
77
+ in your Django project reports its duration to
78
+ [statsd](https://github.com/statsd/statsd).
79
+
80
+ ![Metric names arriving as requests are served](https://raw.githubusercontent.com/WoLpH/django-statsd/master/docs/images/terminal.gif)
81
+
82
+ That recording is real. So is every transcript below: they are produced
83
+ by running the code and checked by the test suite, so a claim on this
84
+ page cannot outlive the behaviour it describes.
85
+
86
+ ## Quick Start
87
+
88
+ ```bash
89
+ pip install django-statsd
90
+ ```
91
+
92
+ ```python
93
+ INSTALLED_APPS = [
94
+ 'django_statsd',
95
+ ]
96
+
97
+ MIDDLEWARE = [
98
+ 'django_statsd.middleware.StatsdMiddleware',
99
+ 'django_statsd.middleware.StatsdMiddlewareTimer',
100
+ ]
101
+
102
+ STATSD_HOST = '127.0.0.1'
103
+ STATSD_PORT = 8125
104
+ STATSD_PREFIX = 'myproject'
105
+ STATSD_TRACK_MIDDLEWARE = True
106
+ ```
107
+
108
+ The tracker goes at the top of `MIDDLEWARE` and the timer at the
109
+ bottom. Your own middlewares go between them, and the pair times
110
+ everything in between.
111
+
112
+ ## What you get
113
+
114
+ One `GET /dashboard/` puts this on the wire:
115
+
116
+ <!-- transcript: views -->
117
+ ```text
118
+ myproject.view.get.myproject.views.dashboard.hit
119
+ myproject.view.get.myproject.views.dashboard.process_request
120
+ myproject.view.get.myproject.views.dashboard.process_response
121
+ myproject.view.get.myproject.views.dashboard.process_view
122
+ myproject.view.get.myproject.views.dashboard.total
123
+ myproject.view.http_codes.2xx
124
+ myproject.view.http_codes.hit
125
+ myproject.view.site.hit
126
+ ```
127
+ <!-- /transcript -->
128
+
129
+ `total` is the request, the three `process_*` names are the phases
130
+ inside it, and `hit` is a counter so you get a rate per view without
131
+ dividing anything. The last two are project-wide.
132
+
133
+ ## Features
134
+
135
+ - **Views** timed by method and dotted view path, with status classes
136
+ counted separately
137
+ - **Queries** timed through Django's `execute_wrapper`, nested under
138
+ the view that ran them, so you get query time per view
139
+ - **Celery tasks** timed per task, plus a counter for every signal
140
+ Celery exports
141
+ - **Templates, `json` and `redis`** patched on import and timed
142
+ without a line of configuration
143
+ - **Your own code**, timed through `request.statsd` or the
144
+ module-level helpers, nested under the view that was running
145
+ - **Async safe.** The scope lives on `asgiref.local.Local`, so ASGI
146
+ and async views report correctly
147
+
148
+ ## Requirements
149
+
150
+ - Python 3.10 through 3.14
151
+ - Django 5.2, 6.0 or 6.1
152
+
153
+ Celery and redis are optional. django-statsd patches them if they
154
+ import and does nothing if they don't.
155
+
156
+ ## Where your milliseconds go
157
+
158
+ ![Timing breakdown of one request](https://raw.githubusercontent.com/WoLpH/django-statsd/master/docs/images/timing_breakdown.png)
159
+
160
+ Recorded from a real request. The phases don't sum to
161
+ `total`, and the gap is the part of the request outside the section
162
+ the middleware pair wraps.
163
+
164
+ ## Usage Examples
165
+
166
+ Time a block inside a view through `request.statsd`:
167
+
168
+ ```python
169
+ def some_view(request):
170
+ with request.statsd.timings('build_queryset'):
171
+ ...
172
+
173
+ def some_other_view(request):
174
+ request.statsd.timings.start('build_queryset')
175
+ ...
176
+ request.statsd.timings.stop('build_queryset')
177
+ ```
178
+
179
+ Or reach the same scope from anywhere during a tracked request, and
180
+ from inside a Celery task:
181
+
182
+ ```python
183
+ import django_statsd
184
+
185
+ with django_statsd.with_('payment.authorise'):
186
+ ...
187
+
188
+ django_statsd.incr('payment.attempt')
189
+
190
+ @django_statsd.decorator('payment')
191
+ def authorise():
192
+ ...
193
+ ```
194
+
195
+ Both land nested inside the view that was running, so the same helper
196
+ called from two views gives you two series.
197
+
198
+ ## In a dashboard
199
+
200
+ ![django-statsd metrics in Grafana](https://raw.githubusercontent.com/WoLpH/django-statsd/master/docs/images/dashboard.png)
201
+
202
+ statsd, Graphite and Grafana, fed by django-statsd over UDP. The
203
+ compose file and the provisioning that produced this are in
204
+ `docs/generate/dashboard/`.
205
+
206
+ ## Settings
207
+
208
+ | Setting | Default | What it does |
209
+ | --- | --- | --- |
210
+ | `STATSD_HOST` | `127.0.0.1` | Where to send |
211
+ | `STATSD_PORT` | `8125` | Which port |
212
+ | `STATSD_PREFIX` | none | Nests every metric under one name |
213
+ | `STATSD_TRACK_MIDDLEWARE` | `False` | The view metrics |
214
+ | `STATSD_TRACK_DATABASE` | `False` | Query timings |
215
+ | `STATSD_SAMPLE_RATE` | `1.0` | Odds a metric is really sent |
216
+ | `STATSD_VIEWS_TO_SKIP` | admin | Regexes of views to ignore |
217
+ | `STATSD_DISABLED` | `False` | Loaded and silent |
218
+ | `STATSD_DEBUG` | `DEBUG` | Warn about unstopped timers |
219
+
220
+ Full list with docstrings:
221
+ [settings reference](https://django-stats.readthedocs.io/en/latest/reference/settings.html).
222
+
223
+ ## Documentation
224
+
225
+ - [Documentation](https://django-stats.readthedocs.io/en/latest/)
226
+ - [Metric reference](https://django-stats.readthedocs.io/en/latest/reference/metrics.html)
227
+ - [Changelog](https://github.com/WoLpH/django-statsd/blob/master/CHANGELOG.md)
228
+
229
+ ## Contributing
230
+
231
+ See [CONTRIBUTING.md](https://github.com/WoLpH/django-statsd/blob/master/CONTRIBUTING.md).
232
+ Every code sample on this page is executed by the test suite, so a
233
+ change to the API that breaks an example fails the build.
234
+
235
+ ## Links
236
+
237
+ - [Source](https://github.com/WoLpH/django-statsd)
238
+ - [Issues](https://github.com/WoLpH/django-statsd/issues)
239
+ - [PyPI](https://pypi.org/project/django-statsd/)
240
+
241
+ ## License
242
+
243
+ [BSD 3-Clause](https://github.com/WoLpH/django-statsd/blob/master/LICENSE)
@@ -0,0 +1,14 @@
1
+ django_statsd/__init__.py,sha256=cPKLckzVLJEr8ygBrvG6S_Yopn1NlYz3JiOxCoQFc4o,541
2
+ django_statsd/celery.py,sha256=2h4Cv-eE2-H9HkL9T8gJyTS6oRfVnUtONIcHoNyBX-c,1985
3
+ django_statsd/database.py,sha256=lXlcxh-J9Jg2Y7tLAD5VGniBVnNLtDWiAz5pURdAado,1103
4
+ django_statsd/json.py,sha256=gnGd0KJ2k8ZQOprpm7vnLC9Z2SUtV9aFVe8N6ZayI-4,1146
5
+ django_statsd/middleware.py,sha256=0YN3-tlUOt6uL6yLhC0g2gx5taOH9T3dfTJACtP8nZI,18523
6
+ django_statsd/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ django_statsd/redis.py,sha256=kvza60PyMU86SLUR2lSaM4zi1s5XKHD2D-IGks7SXac,1951
8
+ django_statsd/settings.py,sha256=HhUkTA4VrQ6c2sX982cMGA6xVfMxhqnvsIsdWNjSHl4,2111
9
+ django_statsd/templates.py,sha256=p1fpQulLP3j8rIT20exnYGhgqkhNR03yKCFbpvEEFGI,672
10
+ django_statsd/utils.py,sha256=5QKe_KwaUdy1itU-Co79T1v7z7c_CD9ajoJYABkY-1s,1297
11
+ django_statsd-3.0.0.dist-info/licenses/LICENSE,sha256=UX7NzXkqWry4CAkM5cI43h1cCZJnWw3gbnEeOVw-9pk,1536
12
+ django_statsd-3.0.0.dist-info/WHEEL,sha256=-i9oRNYVXXZJUIYl5zclLIg6onEb0NLibTX34uln84w,81
13
+ django_statsd-3.0.0.dist-info/METADATA,sha256=K8FaRHkQOV7-G7Fc0ScOgsGF3AJKX3zO8WM5Mb80ncE,8899
14
+ django_statsd-3.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.12.13
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,30 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2012-2026, Rick van Hattem (Wolph)
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice,
10
+ this list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ 3. Neither the name of the copyright holder nor the names of its
17
+ contributors may be used to endorse or promote products derived from
18
+ this software without specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
23
+ ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
24
+ LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
25
+ CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
26
+ SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
27
+ INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
28
+ CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
29
+ ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
30
+ POSSIBILITY OF SUCH DAMAGE.