analytics-platform 0.3.5__tar.gz → 0.3.6__tar.gz

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.
Files changed (50) hide show
  1. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/PKG-INFO +20 -1
  2. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/README.md +19 -0
  3. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/__init__.py +8 -2
  4. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/core.py +1 -1
  5. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/queries.py +416 -4
  6. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/queues.py +5 -1
  7. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform.egg-info/PKG-INFO +20 -1
  8. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform.egg-info/SOURCES.txt +1 -0
  9. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/pyproject.toml +1 -1
  10. analytics_platform-0.3.6/tests/test_redis.py +428 -0
  11. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/LICENSE +0 -0
  12. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/analytics.py +0 -0
  13. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/audit.py +0 -0
  14. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/calls.py +0 -0
  15. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/celery.py +0 -0
  16. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/challenge.py +0 -0
  17. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/crawlers.py +0 -0
  18. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/django.py +0 -0
  19. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/dramatiq.py +0 -0
  20. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/errors.py +0 -0
  21. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/firewall.py +0 -0
  22. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/flags.py +0 -0
  23. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/forms.py +0 -0
  24. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/inventory.py +0 -0
  25. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/maintenance.py +0 -0
  26. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/performance.py +0 -0
  27. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/posture.py +0 -0
  28. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/proxy.py +0 -0
  29. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/request_rules.py +0 -0
  30. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/resources.py +0 -0
  31. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/rq.py +0 -0
  32. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform.egg-info/dependency_links.txt +0 -0
  33. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform.egg-info/top_level.txt +0 -0
  34. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/setup.cfg +0 -0
  35. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/tests/test_calls_queues.py +0 -0
  36. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/tests/test_celery.py +0 -0
  37. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/tests/test_challenge.py +0 -0
  38. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/tests/test_checkin.py +0 -0
  39. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/tests/test_crawlers.py +0 -0
  40. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/tests/test_errors.py +0 -0
  41. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/tests/test_firewall.py +0 -0
  42. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/tests/test_hardening.py +0 -0
  43. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/tests/test_limits.py +0 -0
  44. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/tests/test_page_limits.py +0 -0
  45. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/tests/test_proxy.py +0 -0
  46. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/tests/test_queries.py +0 -0
  47. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/tests/test_request_rules.py +0 -0
  48. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/tests/test_resources.py +0 -0
  49. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/tests/test_rq_dramatiq.py +0 -0
  50. {analytics_platform-0.3.5 → analytics_platform-0.3.6}/tests/test_static_late.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: analytics-platform
3
- Version: 0.3.5
3
+ Version: 0.3.6
4
4
  Summary: Firewall, error monitoring and server analytics for your Python server, run from your analytics dashboard.
5
5
  Author: steviec87
6
6
  License-Expression: MIT
@@ -317,6 +317,8 @@ read, so no data leaves it.
317
317
  | pymongo | nothing, but **create the platform before your `MongoClient`**: pymongo only reports to clients made after the package is listening |
318
318
  | Any other DB-API driver (psycopg, psycopg2, mysqlclient, PyMySQL, sqlite3...) | `conn = platform.watch_database(psycopg.connect(...))`, and use `conn` as before |
319
319
  | ClickHouse: clickhouse-connect, clickhouse-driver | `client = platform.watch_database(clickhouse_connect.get_client(...))` (or a `clickhouse_driver.Client`), and use `client` as before |
320
+ | Redis: redis-py (`Redis`, `RedisCluster`, sync or `redis.asyncio`) | `platform.watch_database(r)`: the client is watched in place, so keep using `r` |
321
+ | Django's cache on Redis (Django's own `RedisCache`, or django-redis) | `platform.watch_database(caches["default"])`, once at start-up (in `AppConfig.ready()`) |
320
322
 
321
323
  A query outside any request or job is counted as `JOB (background)`. Each
322
324
  query is counted once: when SQLAlchemy or Django runs it over a watched
@@ -331,6 +333,23 @@ they answer. clickhouse-driver's `execute()` likewise. The streaming
331
333
  queries (`query_rows_stream()`, `execute_iter()`) aren't timed. Only the
332
334
  client you pass is wrapped; the library itself is left alone.
333
335
 
336
+ Redis: each command is counted by its name and the shape of its key (`GET
337
+ user:?:cart`); no other argument or value is read. A pipeline or transaction
338
+ is one call (`PIPELINE GET user:?`, `MULTI SET cart:?`), timed as a whole.
339
+ Commands that wait (`BLPOP`, `XREAD BLOCK`, subscribing) aren't timed, and
340
+ nor are this package's own reads of your RQ queues. An error reply counts
341
+ as failed; a nil reply doesn't.
342
+
343
+ ```python
344
+ r = redis.Redis.from_url(REDIS_URL)
345
+ platform.watch_database(r)
346
+
347
+ # Django (settings: CACHES backend django.core.cache.backends.redis.RedisCache
348
+ # or django_redis.cache.RedisCache), in your AppConfig.ready():
349
+ from django.core.cache import caches
350
+ platform.watch_database(caches["default"])
351
+ ```
352
+
334
353
  ## Outgoing calls
335
354
 
336
355
  Health → Server speed → Calls shows how long each route waited on other
@@ -291,6 +291,8 @@ read, so no data leaves it.
291
291
  | pymongo | nothing, but **create the platform before your `MongoClient`**: pymongo only reports to clients made after the package is listening |
292
292
  | Any other DB-API driver (psycopg, psycopg2, mysqlclient, PyMySQL, sqlite3...) | `conn = platform.watch_database(psycopg.connect(...))`, and use `conn` as before |
293
293
  | ClickHouse: clickhouse-connect, clickhouse-driver | `client = platform.watch_database(clickhouse_connect.get_client(...))` (or a `clickhouse_driver.Client`), and use `client` as before |
294
+ | Redis: redis-py (`Redis`, `RedisCluster`, sync or `redis.asyncio`) | `platform.watch_database(r)`: the client is watched in place, so keep using `r` |
295
+ | Django's cache on Redis (Django's own `RedisCache`, or django-redis) | `platform.watch_database(caches["default"])`, once at start-up (in `AppConfig.ready()`) |
294
296
 
295
297
  A query outside any request or job is counted as `JOB (background)`. Each
296
298
  query is counted once: when SQLAlchemy or Django runs it over a watched
@@ -305,6 +307,23 @@ they answer. clickhouse-driver's `execute()` likewise. The streaming
305
307
  queries (`query_rows_stream()`, `execute_iter()`) aren't timed. Only the
306
308
  client you pass is wrapped; the library itself is left alone.
307
309
 
310
+ Redis: each command is counted by its name and the shape of its key (`GET
311
+ user:?:cart`); no other argument or value is read. A pipeline or transaction
312
+ is one call (`PIPELINE GET user:?`, `MULTI SET cart:?`), timed as a whole.
313
+ Commands that wait (`BLPOP`, `XREAD BLOCK`, subscribing) aren't timed, and
314
+ nor are this package's own reads of your RQ queues. An error reply counts
315
+ as failed; a nil reply doesn't.
316
+
317
+ ```python
318
+ r = redis.Redis.from_url(REDIS_URL)
319
+ platform.watch_database(r)
320
+
321
+ # Django (settings: CACHES backend django.core.cache.backends.redis.RedisCache
322
+ # or django_redis.cache.RedisCache), in your AppConfig.ready():
323
+ from django.core.cache import caches
324
+ platform.watch_database(caches["default"])
325
+ ```
326
+
308
327
  ## Outgoing calls
309
328
 
310
329
  Health → Server speed → Calls shows how long each route waited on other
@@ -342,12 +342,18 @@ class Platform:
342
342
  sqlite3...) or a ClickHouse client (clickhouse-connect,
343
343
  clickhouse-driver) whose queries are timed for Server speed: use what
344
344
  it returns in its place. db: "postgresql", "mysql"... when the driver
345
- doesn't say. Django, SQLAlchemy and pymongo need nothing of this."""
345
+ doesn't say. Django, SQLAlchemy and pymongo need nothing of this.
346
+ A redis-py client (Redis, RedisCluster, sync or asyncio) or a Django
347
+ cache on Redis is watched in place and handed back as itself."""
346
348
  queries = self._queries()
347
349
  if queries is None:
348
350
  return connection
349
351
  from .queries import watch_database
350
- return watch_database(connection, db)
352
+ try:
353
+ return watch_database(connection, db)
354
+ except Exception as error:
355
+ self.core.log("performance", "watch_database: its queries aren't timed:", error)
356
+ return connection
351
357
 
352
358
  def watch_http(self, client):
353
359
  """A requests Session, an httpx Client or AsyncClient, or an httpx
@@ -23,7 +23,7 @@ import urllib.error
23
23
  import urllib.parse
24
24
  import urllib.request
25
25
 
26
- VERSION = "0.3.5"
26
+ VERSION = "0.3.6"
27
27
  TIMEOUT = 5
28
28
  MIN_SYNC = 30
29
29
  RETRY = 60
@@ -15,6 +15,8 @@ Caught by, with nothing to set up beyond the platform itself:
15
15
  sqlite3...): conn = platform.watch_database(conn).
16
16
  - ClickHouse: a clickhouse-connect client (query, command, insert...) or a
17
17
  clickhouse-driver Client (execute): client = platform.watch_database(client).
18
+ - Redis: a redis-py client (Redis, RedisCluster, sync or asyncio), watched in
19
+ place: platform.watch_database(r); a Django cache on Redis the same way.
18
20
 
19
21
  A query is timed at one layer only: the outer one (Django, SQLAlchemy) marks
20
22
  it as being timed, and a watched connection under it leaves it alone.
@@ -24,6 +26,7 @@ alongside, by call method and host (calls.py has the hooks), and go to
24
26
  POST /events/server-calls; calls=False leaves them out.
25
27
  """
26
28
  import contextvars
29
+ import inspect
27
30
  import json
28
31
  import os
29
32
  import re
@@ -32,6 +35,7 @@ import threading
32
35
  import time
33
36
  import weakref
34
37
  from collections.abc import Mapping
38
+ from contextlib import contextmanager
35
39
  from datetime import datetime, timezone
36
40
  from importlib.util import find_spec
37
41
 
@@ -41,7 +45,7 @@ MAX_STATEMENT = 1000
41
45
  OTHER_STATEMENT = "(other)"
42
46
  BACKGROUND = ("JOB", "(background)")
43
47
  # The databases a row may name; anything else is "other".
44
- DATABASES = frozenset(("postgresql", "mysql", "mariadb", "sqlite", "mssql", "oracle", "clickhouse", "mongodb", "other"))
48
+ DATABASES = frozenset(("postgresql", "mysql", "mariadb", "sqlite", "mssql", "oracle", "clickhouse", "mongodb", "redis", "other"))
45
49
  # Past this many statements in one request, new ones are one "(other)".
46
50
  PER_REQUEST = 50
47
51
  # Past MAX_KEYS in a minute new statements fold into "(other)"; past
@@ -64,6 +68,9 @@ current_queries = contextvars.ContextVar("analytics_platform_queries", default=N
64
68
  # True while an outer layer (Django, SQLAlchemy) times a query, so a watched
65
69
  # connection under it doesn't count it again.
66
70
  _timing = contextvars.ContextVar("analytics_platform_query_timing", default=False)
71
+ # True while the package reads Redis for itself (a queue's depth, queues.py):
72
+ # a watched client it shares with the app leaves those commands out.
73
+ _own_redis = contextvars.ContextVar("analytics_platform_own_redis", default=False)
67
74
 
68
75
  # ---- Normalizing: a port of expresscollect/Analytics/queryText.js ----
69
76
 
@@ -264,6 +271,142 @@ def _mongo_statement(text):
264
271
  return " ".join(part for part in (command, collection, shape) if part)
265
272
 
266
273
 
274
+ # Redis: the command and its first key's shape, never an argument or value
275
+ # (queries.md, "Redis"). A key keeps each ":" segment that is only letters
276
+ # and "_" (32 at most); every other segment is "?", so IDs, slugs, emails and
277
+ # hashes never leave the server: user:42:cart -> user:?:cart.
278
+ _REDIS_WORD = re.compile(r"[A-Za-z][A-Za-z0-9_.|-]{0,39}\Z")
279
+ _REDIS_SEGMENT = re.compile(r"[A-Za-z_]{1,32}\Z")
280
+ # Commands whose second word is a subcommand (CLIENT SETNAME, XINFO STREAM).
281
+ _REDIS_CONTAINERS = frozenset(("ACL", "CLIENT", "CLUSTER", "COMMAND", "CONFIG", "DEBUG", "FUNCTION", "LATENCY", "MEMORY",
282
+ "MODULE", "OBJECT", "PUBSUB", "SCRIPT", "SLOWLOG", "XGROUP", "XINFO"))
283
+ # Of those, the ones with a key after the subcommand.
284
+ _REDIS_SUB_KEY = frozenset(("OBJECT", "MEMORY", "XGROUP", "XINFO"))
285
+ # No key: what follows is a password, a message, a number or nothing.
286
+ _REDIS_NO_KEY = frozenset(("PING", "ECHO", "INFO", "AUTH", "HELLO", "SELECT", "QUIT", "RESET", "MULTI", "EXEC", "DISCARD",
287
+ "UNWATCH", "DBSIZE", "TIME", "FLUSHDB", "FLUSHALL", "SAVE", "BGSAVE", "BGREWRITEAOF", "LASTSAVE",
288
+ "SCAN", "RANDOMKEY", "SWAPDB", "SHUTDOWN", "ROLE", "REPLICAOF", "SLAVEOF", "READONLY", "READWRITE",
289
+ "LOLWUT", "FAILOVER", "PSYNC", "SYNC", "REPLCONF", "MIGRATE"))
290
+ # Script calls: script or name, then how many keys, then the keys.
291
+ _REDIS_SCRIPTS = frozenset(("EVAL", "EVALSHA", "EVAL_RO", "EVALSHA_RO", "FCALL", "FCALL_RO"))
292
+ # How many keys first, then the keys.
293
+ _REDIS_NUMKEYS = frozenset(("ZUNION", "ZINTER", "ZDIFF", "SINTERCARD", "ZINTERCARD", "LMPOP", "ZMPOP"))
294
+ # Waiting, not working: a queue worker sits in these for seconds, so timing
295
+ # them would put every idle worker at the top of the slow list.
296
+ _REDIS_WAITING = frozenset(("BLPOP", "BRPOP", "BRPOPLPUSH", "BLMOVE", "BLMPOP", "BZPOPMIN", "BZPOPMAX", "BZMPOP", "WAIT",
297
+ "WAITAOF", "SUBSCRIBE", "PSUBSCRIBE", "SSUBSCRIBE", "UNSUBSCRIBE", "PUNSUBSCRIBE",
298
+ "SUNSUBSCRIBE", "MONITOR"))
299
+ _REDIS_PREFIXES = frozenset(("PIPELINE", "MULTI"))
300
+ # ASCII only, as JavaScript's /[a-z]/: str.upper() would change "ß" and "ı".
301
+ _ASCII_UPPER = str.maketrans("abcdefghijklmnopqrstuvwxyz", "ABCDEFGHIJKLMNOPQRSTUVWXYZ")
302
+ _JS_DECIMAL = re.compile(r"[+-]?(?:[0-9]+\.?[0-9]*|\.[0-9]+)(?:[eE][+-]?[0-9]+)?\Z")
303
+ _JS_RADIX = re.compile(r"0[xXoObB][0-9a-fA-F]+\Z")
304
+ _SPACE_RUN = re.compile(_S + "+")
305
+
306
+
307
+ def _upper(text):
308
+ return text.translate(_ASCII_UPPER)
309
+
310
+
311
+ def _positive(value):
312
+ """JavaScript's Number(value) > 0, for what a command's argument can be."""
313
+ if isinstance(value, bool):
314
+ return value
315
+ if isinstance(value, (int, float)):
316
+ return value > 0
317
+ if not isinstance(value, str):
318
+ return False
319
+ text = value.strip(_SPACE)
320
+ if _JS_DECIMAL.match(text):
321
+ return float(text) > 0
322
+ if _JS_RADIX.match(text):
323
+ try:
324
+ return int(text, 0) > 0
325
+ except ValueError:
326
+ return False
327
+ return text in ("Infinity", "+Infinity")
328
+
329
+
330
+ def redis_key_shape(key):
331
+ # An empty key is "?" too: "GET " would come back from the collector as "GET".
332
+ if not isinstance(key, str) or key == "":
333
+ return "?"
334
+ return ":".join(part if part == "" or _REDIS_SEGMENT.match(part) else "?" for part in key.split(":"))
335
+
336
+
337
+ def redis_command(args):
338
+ """A command as the client sends it (["GET", "user:42"]) -> "GET user:?",
339
+ or None for one that isn't timed (blocking, or not a command)."""
340
+ if not isinstance(args, (list, tuple)) or not args or not isinstance(args[0], str) or not _REDIS_WORD.match(args[0]):
341
+ return None
342
+ name = _upper(args[0])
343
+
344
+ def word(i):
345
+ return _upper(args[i]) if isinstance(args[i], str) else ""
346
+
347
+ if name in _REDIS_WAITING:
348
+ return None
349
+ command = name
350
+ key = None
351
+ if name in _REDIS_CONTAINERS:
352
+ if len(args) > 1 and isinstance(args[1], str) and _REDIS_WORD.match(args[1]):
353
+ command = name + " " + _upper(args[1])
354
+ # Only after a subcommand: "OBJECT user" would read back as OBJECT USER.
355
+ if name in _REDIS_SUB_KEY:
356
+ key = 2
357
+ elif name in _REDIS_SCRIPTS:
358
+ if len(args) > 2 and _positive(args[2]):
359
+ key = 3
360
+ elif name in _REDIS_NUMKEYS:
361
+ if len(args) > 1 and _positive(args[1]):
362
+ key = 2
363
+ elif name == "BITOP":
364
+ key = 2
365
+ elif name in ("XREAD", "XREADGROUP"):
366
+ # Its options, then STREAMS and the keys. XREADGROUP's group and
367
+ # consumer come first and may be named anything, as may a stream ("block").
368
+ at = -1
369
+ for i in range(4 if name == "XREADGROUP" else 1, len(args)):
370
+ if word(i) == "BLOCK":
371
+ return None
372
+ if word(i) == "STREAMS":
373
+ at = i
374
+ break
375
+ if at > 0:
376
+ key = at + 1
377
+ elif name not in _REDIS_NO_KEY and name not in _REDIS_PREFIXES:
378
+ key = 1
379
+ return command + " " + redis_key_shape(args[key]) if key is not None and key < len(args) else command
380
+
381
+
382
+ def _redis_statement(text):
383
+ """A statement a package sent: [PIPELINE|MULTI] COMMAND [SUBCOMMAND] [key].
384
+ Anything else (an argument a package let through) is (other)."""
385
+ words = _SPACE_RUN.split(text.strip(_SPACE))
386
+ if words[0] == "":
387
+ return ""
388
+ out = []
389
+ i = 0
390
+ if _upper(words[0]) in _REDIS_PREFIXES and len(words) > 1:
391
+ out.append(_upper(words[0]))
392
+ i = 1
393
+ if not _REDIS_WORD.match(words[i]):
394
+ return OTHER_STATEMENT
395
+ name = _upper(words[i])
396
+ i += 1
397
+ if name in _REDIS_WAITING:
398
+ return OTHER_STATEMENT
399
+ out.append(name)
400
+ if name in _REDIS_CONTAINERS and i < len(words) and _REDIS_WORD.match(words[i]):
401
+ out.append(_upper(words[i]))
402
+ i += 1
403
+ if i < len(words) - 1:
404
+ return OTHER_STATEMENT
405
+ if i < len(words):
406
+ out.append(redis_key_shape(words[i]))
407
+ return " ".join(out)
408
+
409
+
267
410
  def normalize_statement(text, db):
268
411
  """The statement to store, or "" when nothing is left of it."""
269
412
  if not isinstance(text, str):
@@ -273,6 +416,8 @@ def normalize_statement(text, db):
273
416
  db = database_of(db)
274
417
  if db == "mongodb":
275
418
  shaped = _mongo_statement(text)
419
+ elif db == "redis":
420
+ shaped = _redis_statement(text)
276
421
  elif db == "other" and "\\" in text:
277
422
  # A database we don't know may or may not take a backslash as an
278
423
  # escape, and each reading leaks what the other hides. When they
@@ -1047,12 +1192,279 @@ def _clickhouse_client(connection, root):
1047
1192
  return None
1048
1193
 
1049
1194
 
1195
+ # ---- Redis clients: watch_database(client) (queries.md, "Redis") ----
1196
+ # Each command is one query under db "redis": its name and its first key's
1197
+ # shape (redis_command), never an argument. A pipeline or transaction is one,
1198
+ # timed as a whole: "PIPELINE " or "MULTI " and its first command. Blocking
1199
+ # commands (BLPOP, XREAD BLOCK) aren't timed. An error reply or no answer is
1200
+ # a failure; a nil reply isn't, and nor is a transaction a WATCH called off.
1201
+ #
1202
+ # The client itself is watched, not wrapped: its own execute_command (every
1203
+ # command goes through it) and pipeline() are replaced on that one object, so
1204
+ # it stays the client you made and nothing else is changed.
1205
+
1206
+ _REDIS_MARK = "_analytics_platform_watched"
1207
+ # What redis-py splits a command's first argument on ("CLIENT SETNAME").
1208
+ _ASCII_SPACE = re.compile(r"[ \t\n\r\x0b\x0c]+")
1209
+ # Not what names a batch: its framing, and a WATCH a cluster pipeline sends at once.
1210
+ _REDIS_FRAMING = frozenset(("MULTI", "EXEC", "DISCARD", "WATCH", "UNWATCH"))
1211
+
1212
+
1213
+ @contextmanager
1214
+ def own_redis():
1215
+ """The package reading Redis for itself: a watched client leaves it out."""
1216
+ token = _own_redis.set(True)
1217
+ try:
1218
+ yield
1219
+ finally:
1220
+ _own_redis.reset(token)
1221
+
1222
+
1223
+ def _redis_arg(value):
1224
+ """Bytes as text, as a key is read; a long value isn't a key worth
1225
+ decoding. Anything else as it is (a number makes "?")."""
1226
+ if isinstance(value, (bytes, bytearray, memoryview)):
1227
+ return bytes(value).decode("utf-8", "replace") if len(value) <= 1024 else None
1228
+ return value
1229
+
1230
+
1231
+ def _redis_list(args):
1232
+ """The command as redis_command reads it: its first argument split into
1233
+ words, as redis-py does to send it, then the rest. The key is at most the
1234
+ third argument, so only the first few are taken, but all of XREAD's."""
1235
+ if not args:
1236
+ return None
1237
+ first = _redis_arg(args[0])
1238
+ head = [w for w in _ASCII_SPACE.split(first) if w] if isinstance(first, str) and _ASCII_SPACE.search(first) else [first]
1239
+ name = head[0] if head else ""
1240
+ end = len(args) if isinstance(name, str) and _upper(name) in ("XREAD", "XREADGROUP") else min(len(args), 5 - len(head))
1241
+ return head + [_redis_arg(args[i]) for i in range(1, end)]
1242
+
1243
+
1244
+ def _redis_start(args):
1245
+ """The statement a command starting now is timed as, or None."""
1246
+ if _own_redis.get() or not _active():
1247
+ return None
1248
+ try:
1249
+ return redis_command(_redis_list(args))
1250
+ except Exception:
1251
+ return None
1252
+
1253
+
1254
+ def _redis_failed(error):
1255
+ # EXEC answering nil: the transaction a WATCH called off.
1256
+ return error is not None and type(error).__name__ != "WatchError"
1257
+
1258
+
1259
+ def _replies_failed(replies):
1260
+ """A pipeline run with raise_on_error=False hands its errors back."""
1261
+ return isinstance(replies, (list, tuple)) and any(isinstance(r, Exception) and _redis_failed(r) for r in replies)
1262
+
1263
+
1264
+ def _redis_record(statement, started, error, replies=None):
1265
+ try:
1266
+ failed = _redis_failed(error) or _replies_failed(replies)
1267
+ record("redis", statement, (time.perf_counter() - started) * 1000, failed)
1268
+ except Exception:
1269
+ pass # never in place of the command's own result or error
1270
+
1271
+
1272
+ def _redis_timed(call, begin, batch=False):
1273
+ """call (sync or async) timed as the statement begin(args) gives, or run
1274
+ untimed when that's None. A batch's replies can hold its error replies."""
1275
+ if inspect.iscoroutinefunction(call):
1276
+ async def timed_async(*args, **kwargs):
1277
+ statement = begin(args)
1278
+ if statement is None:
1279
+ return await call(*args, **kwargs)
1280
+ started, error, result = time.perf_counter(), None, None
1281
+ try:
1282
+ result = await call(*args, **kwargs)
1283
+ return result
1284
+ except BaseException as raised:
1285
+ error = raised
1286
+ raise
1287
+ finally:
1288
+ _redis_record(statement, started, error, result if batch else None)
1289
+ return timed_async
1290
+
1291
+ def timed(*args, **kwargs):
1292
+ statement = begin(args)
1293
+ if statement is None:
1294
+ return call(*args, **kwargs)
1295
+ started, error, result = time.perf_counter(), None, None
1296
+ try:
1297
+ result = call(*args, **kwargs)
1298
+ return result
1299
+ except BaseException as raised:
1300
+ error = raised
1301
+ raise
1302
+ finally:
1303
+ _redis_record(statement, started, error, result if batch else None)
1304
+ return timed
1305
+
1306
+
1307
+ def _timed_batch(pipe, call, batch):
1308
+ """call, a pipeline's execute, timed as one call named by its first command."""
1309
+ def begin(_args):
1310
+ first, batch[0] = batch[0], None
1311
+ if first is None or _own_redis.get() or not _active():
1312
+ return None
1313
+ # transaction (sync), is_transaction (asyncio), _transaction (an asyncio
1314
+ # cluster's); a sync cluster's says so only by the strategy it runs.
1315
+ multi = any(getattr(pipe, name, False) is True
1316
+ for name in ("transaction", "is_transaction", "_transaction", "explicit_transaction")) \
1317
+ or type(getattr(pipe, "_execution_strategy", None)).__name__ == "TransactionStrategy"
1318
+ return ("MULTI " if multi else "PIPELINE ") + first
1319
+
1320
+ return _redis_timed(call, begin, batch=True)
1321
+
1322
+
1323
+ def _watch_redis_pipeline(pipe):
1324
+ """A pipeline (or transaction) from a watched client: the commands queued
1325
+ name it, execute() times it. The ones a WATCH makes run at once (redis-py's
1326
+ immediate_execute_command) are timed on their own."""
1327
+ batch = [None]
1328
+
1329
+ def note(args):
1330
+ if batch[0] is None:
1331
+ statement = _redis_start(args)
1332
+ if statement and statement.split(" ", 1)[0] not in _REDIS_FRAMING:
1333
+ batch[0] = statement
1334
+
1335
+ immediate = getattr(pipe, "immediate_execute_command", None)
1336
+ staged = "pipeline_execute_command" if callable(immediate) and callable(getattr(pipe, "pipeline_execute_command", None)) \
1337
+ else "execute_command"
1338
+ queue = getattr(pipe, staged)
1339
+
1340
+ def queued(*args, **options):
1341
+ note(args)
1342
+ return queue(*args, **options)
1343
+
1344
+ setattr(pipe, staged, queued)
1345
+ if staged == "pipeline_execute_command":
1346
+ pipe.immediate_execute_command = _redis_timed(immediate, _redis_start)
1347
+ reset = getattr(pipe, "reset", None)
1348
+ if callable(reset):
1349
+ def reset_batch(*args, **kwargs):
1350
+ batch[0] = None
1351
+ return reset(*args, **kwargs)
1352
+ pipe.reset = reset_batch
1353
+ pipe.execute = _timed_batch(pipe, pipe.execute, batch)
1354
+ return pipe
1355
+
1356
+
1357
+ def _watch_redis(client):
1358
+ """A redis-py client (sync or asyncio, Redis or RedisCluster), in place."""
1359
+ if getattr(client, _REDIS_MARK, False):
1360
+ return client
1361
+ client.execute_command = _redis_timed(client.execute_command, _redis_start)
1362
+ pipeline = getattr(client, "pipeline", None)
1363
+ if callable(pipeline):
1364
+ def watched_pipeline(*args, **kwargs):
1365
+ pipe = pipeline(*args, **kwargs)
1366
+ try:
1367
+ _watch_redis_pipeline(pipe)
1368
+ except Exception:
1369
+ pass # its commands still run, untimed
1370
+ return pipe
1371
+ client.pipeline = watched_pipeline
1372
+ setattr(client, _REDIS_MARK, True)
1373
+ return client
1374
+
1375
+
1376
+ def _is_redis_client(client):
1377
+ """redis-py's (or valkey-py's) Redis or RedisCluster, or a subclass of one."""
1378
+ if not callable(getattr(client, "execute_command", None)) or not callable(getattr(client, "pipeline", None)):
1379
+ return False
1380
+ return any(klass.__module__.split(".")[0] in ("redis", "valkey") for klass in type(client).__mro__)
1381
+
1382
+
1383
+ def _making_watched(make):
1384
+ """make, a function that makes (or connects) a Redis client, made to
1385
+ hand back each client watched."""
1386
+ def make_watched(*args, **kwargs):
1387
+ client = make(*args, **kwargs)
1388
+ # Inside the app's cache call: a client we can't watch (a custom
1389
+ # CLIENT_CLASS's) is handed back as it is, never an error.
1390
+ try:
1391
+ if _is_redis_client(client):
1392
+ _watch_redis(client)
1393
+ except Exception:
1394
+ pass
1395
+ return client
1396
+ setattr(make_watched, _REDIS_MARK, True)
1397
+ return make_watched
1398
+
1399
+
1400
+ def _watch_django_cache(cache):
1401
+ """A Django cache on Redis: Django's own RedisCache makes a client for each
1402
+ operation, so the clients it makes are watched as it makes them;
1403
+ django-redis keeps its clients, and those it has and connects are."""
1404
+ module = type(cache).__module__
1405
+ if module.startswith("django.core.cache.backends.redis"):
1406
+ inner = cache._cache
1407
+ if not getattr(inner._client, _REDIS_MARK, False):
1408
+ inner._client = _making_watched(inner._client)
1409
+ return True
1410
+ if module.startswith("django_redis"):
1411
+ inner = cache.client
1412
+ connect = getattr(inner, "connect", None)
1413
+ if not callable(connect) or getattr(connect, _REDIS_MARK, False):
1414
+ return callable(connect)
1415
+ inner.connect = _making_watched(connect)
1416
+ held = list(getattr(inner, "_clients", None) or []) + list((getattr(inner, "_serverdict", None) or {}).values())
1417
+ for client in held:
1418
+ if client is not None and _is_redis_client(client):
1419
+ _watch_redis(client)
1420
+ return True
1421
+ return False
1422
+
1423
+
1424
+ def _watch_django_alias(cache):
1425
+ """Django keeps a cache object per thread: the one watched is this
1426
+ thread's. The others of its alias are watched as Django makes them."""
1427
+ try:
1428
+ from django.core.cache import caches
1429
+ except Exception:
1430
+ return
1431
+ try:
1432
+ alias = next((name for name in caches if caches[name] is cache), None)
1433
+ if alias is None:
1434
+ return
1435
+ aliases = getattr(caches, _REDIS_MARK, None)
1436
+ if aliases is None:
1437
+ create = caches.create_connection
1438
+
1439
+ def create_watched(name, *args, **kwargs):
1440
+ made = create(name, *args, **kwargs)
1441
+ if name in aliases:
1442
+ try:
1443
+ _watch_django_cache(made)
1444
+ except Exception:
1445
+ pass # that thread's cache still works, untimed
1446
+ return made
1447
+ aliases = set()
1448
+ caches.create_connection = create_watched
1449
+ setattr(caches, _REDIS_MARK, aliases)
1450
+ aliases.add(alias)
1451
+ except Exception:
1452
+ pass # this thread's cache is still watched
1453
+
1454
+
1050
1455
  def watch_database(connection, db=None):
1051
- """A DB-API connection, or a clickhouse-connect / clickhouse-driver
1052
- client, whose queries are timed: use what it returns in place of the
1053
- connection. db: one of DATABASES (default: by its driver)."""
1456
+ """A DB-API connection, a clickhouse-connect / clickhouse-driver client,
1457
+ a redis-py client or a Django cache on Redis, whose queries are timed:
1458
+ use what it returns in place of the connection (a Redis client or cache
1459
+ comes back as itself, watched). db: one of DATABASES (default: by its driver)."""
1054
1460
  if isinstance(connection, (_Connection, _ClickHouse)):
1055
1461
  return connection
1462
+ if _is_redis_client(connection):
1463
+ return _watch_redis(connection)
1464
+ if type(connection).__module__.startswith(("django.core.cache.backends.redis", "django_redis")):
1465
+ if _watch_django_cache(connection):
1466
+ _watch_django_alias(connection)
1467
+ return connection
1056
1468
  root = type(connection).__module__.split(".")[0]
1057
1469
  client = _clickhouse_client(connection, root)
1058
1470
  if client is not None:
@@ -23,6 +23,7 @@ import weakref
23
23
  from datetime import datetime, timezone
24
24
 
25
25
  from .core import Periodic
26
+ from .queries import own_redis
26
27
 
27
28
  SAMPLE_EVERY = 60
28
29
  MAX_QUEUES = 100
@@ -126,7 +127,10 @@ class Queues:
126
127
  for entry in list(self.entries.values()):
127
128
  gauges = None
128
129
  try:
129
- gauges = entry.read()
130
+ # Through the app's own connection (RQ's), maybe watched:
131
+ # our reads aren't its commands.
132
+ with own_redis():
133
+ gauges = entry.read()
130
134
  except Exception as error:
131
135
  self.core.log("performance", "could not read queue %s:" % entry.queue, error)
132
136
  with self.lock:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: analytics-platform
3
- Version: 0.3.5
3
+ Version: 0.3.6
4
4
  Summary: Firewall, error monitoring and server analytics for your Python server, run from your analytics dashboard.
5
5
  Author: steviec87
6
6
  License-Expression: MIT
@@ -317,6 +317,8 @@ read, so no data leaves it.
317
317
  | pymongo | nothing, but **create the platform before your `MongoClient`**: pymongo only reports to clients made after the package is listening |
318
318
  | Any other DB-API driver (psycopg, psycopg2, mysqlclient, PyMySQL, sqlite3...) | `conn = platform.watch_database(psycopg.connect(...))`, and use `conn` as before |
319
319
  | ClickHouse: clickhouse-connect, clickhouse-driver | `client = platform.watch_database(clickhouse_connect.get_client(...))` (or a `clickhouse_driver.Client`), and use `client` as before |
320
+ | Redis: redis-py (`Redis`, `RedisCluster`, sync or `redis.asyncio`) | `platform.watch_database(r)`: the client is watched in place, so keep using `r` |
321
+ | Django's cache on Redis (Django's own `RedisCache`, or django-redis) | `platform.watch_database(caches["default"])`, once at start-up (in `AppConfig.ready()`) |
320
322
 
321
323
  A query outside any request or job is counted as `JOB (background)`. Each
322
324
  query is counted once: when SQLAlchemy or Django runs it over a watched
@@ -331,6 +333,23 @@ they answer. clickhouse-driver's `execute()` likewise. The streaming
331
333
  queries (`query_rows_stream()`, `execute_iter()`) aren't timed. Only the
332
334
  client you pass is wrapped; the library itself is left alone.
333
335
 
336
+ Redis: each command is counted by its name and the shape of its key (`GET
337
+ user:?:cart`); no other argument or value is read. A pipeline or transaction
338
+ is one call (`PIPELINE GET user:?`, `MULTI SET cart:?`), timed as a whole.
339
+ Commands that wait (`BLPOP`, `XREAD BLOCK`, subscribing) aren't timed, and
340
+ nor are this package's own reads of your RQ queues. An error reply counts
341
+ as failed; a nil reply doesn't.
342
+
343
+ ```python
344
+ r = redis.Redis.from_url(REDIS_URL)
345
+ platform.watch_database(r)
346
+
347
+ # Django (settings: CACHES backend django.core.cache.backends.redis.RedisCache
348
+ # or django_redis.cache.RedisCache), in your AppConfig.ready():
349
+ from django.core.cache import caches
350
+ platform.watch_database(caches["default"])
351
+ ```
352
+
334
353
  ## Outgoing calls
335
354
 
336
355
  Health → Server speed → Calls shows how long each route waited on other
@@ -41,6 +41,7 @@ tests/test_limits.py
41
41
  tests/test_page_limits.py
42
42
  tests/test_proxy.py
43
43
  tests/test_queries.py
44
+ tests/test_redis.py
44
45
  tests/test_request_rules.py
45
46
  tests/test_resources.py
46
47
  tests/test_rq_dramatiq.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "analytics-platform"
7
- version = "0.3.5"
7
+ version = "0.3.6"
8
8
  description = "Firewall, error monitoring and server analytics for your Python server, run from your analytics dashboard."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -0,0 +1,428 @@
1
+ """Redis command timings (sdk/protocols/queries.md, "Redis"): redis_command
2
+ against the shared cases, and the redis-py hooks (sync, asyncio, pipelines,
3
+ transactions, a Django cache) against stand-ins shaped as redis-py's objects
4
+ are; the package doesn't depend on redis-py.
5
+
6
+ python3 -m unittest discover -s tests
7
+ """
8
+ import asyncio
9
+ import json
10
+ import os
11
+ import sys
12
+ import threading
13
+ import types
14
+ import unittest
15
+ from unittest import mock
16
+
17
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
18
+
19
+ from analytics_platform import create_platform # noqa: E402
20
+ from analytics_platform import queries as q # noqa: E402
21
+
22
+ CASES = os.path.join(os.path.dirname(__file__), "..", "..", "protocols", "query-cases.json")
23
+ SITE = "a1b2c3d4e5f6a1b2c3d4e5f6"
24
+
25
+
26
+ def make_platform(**options):
27
+ return create_platform(site_id=SITE, secret="s", endpoint="http://127.0.0.1:9",
28
+ firewall=False, analytics=False, flags=False, inventory=False, posture=False,
29
+ resources=False, proxy=False, maintenance=False, crawlers=False, forms=False,
30
+ audit=False, headers=False, errors=False, **options)
31
+
32
+
33
+ class ResponseError(Exception):
34
+ pass
35
+
36
+
37
+ class WatchError(Exception):
38
+ pass
39
+
40
+
41
+ def reply(args):
42
+ """What the stand-in server answers: nil for "missing", an error reply for "text"."""
43
+ key = args[1] if len(args) > 1 else None
44
+ if isinstance(key, bytes):
45
+ key = key.decode()
46
+ if key == "missing":
47
+ return None
48
+ if key == "text":
49
+ raise ResponseError("WRONGTYPE Operation against a key holding the wrong kind of value")
50
+ return b"v"
51
+
52
+
53
+ # ---- redis-py's shapes: every command through the client's execute_command;
54
+ # a Pipeline (a Redis subclass) stages commands, runs WATCH and what follows
55
+ # it at once, and sends the rest on execute() ----
56
+
57
+ class Redis:
58
+ def __init__(self):
59
+ self.sent = []
60
+
61
+ def execute_command(self, *args, **options):
62
+ self.sent.append(args)
63
+ return reply(args)
64
+
65
+ def get(self, name):
66
+ return self.execute_command("GET", name)
67
+
68
+ def incr(self, name):
69
+ return self.execute_command("INCR", name)
70
+
71
+ def client_setname(self, name):
72
+ return self.execute_command("CLIENT SETNAME", name)
73
+
74
+ def blpop(self, keys, timeout=0):
75
+ return self.execute_command("BLPOP", *keys, timeout)
76
+
77
+ def eval(self, script, numkeys, *keys_and_args):
78
+ return self.execute_command("EVAL", script, numkeys, *keys_and_args)
79
+
80
+ def pipeline(self, transaction=True, shard_hint=None):
81
+ return Pipeline(self, transaction)
82
+
83
+ def __enter__(self):
84
+ return self
85
+
86
+ def __exit__(self, *exc):
87
+ return None
88
+
89
+
90
+ class Pipeline(Redis):
91
+ def __init__(self, redis, transaction):
92
+ super().__init__()
93
+ self.redis = redis
94
+ self.transaction = transaction
95
+ self.stack = []
96
+ self.watching = False
97
+ self.explicit_transaction = False
98
+
99
+ def execute_command(self, *args, **kwargs):
100
+ if (self.watching or args[0] == "WATCH") and not self.explicit_transaction:
101
+ return self.immediate_execute_command(*args, **kwargs)
102
+ return self.pipeline_execute_command(*args, **kwargs)
103
+
104
+ def immediate_execute_command(self, *args, **options):
105
+ if args[0] == "WATCH":
106
+ self.watching = True
107
+ return True
108
+ return reply(args)
109
+
110
+ def pipeline_execute_command(self, *args, **options):
111
+ self.stack.append(args)
112
+ return self
113
+
114
+ def watch(self, *names):
115
+ return self.execute_command("WATCH", *names)
116
+
117
+ def multi(self):
118
+ self.explicit_transaction = True
119
+
120
+ def reset(self):
121
+ self.stack, self.watching, self.explicit_transaction = [], False, False
122
+
123
+ def execute(self, raise_on_error=True):
124
+ try:
125
+ if any(args[1:2] == ("changed",) for args in self.stack):
126
+ raise WatchError("Watched variable changed.")
127
+ replies = []
128
+ for args in self.stack:
129
+ try:
130
+ replies.append(reply(args))
131
+ except ResponseError as error:
132
+ if raise_on_error:
133
+ raise
134
+ replies.append(error)
135
+ return replies
136
+ finally:
137
+ Pipeline.reset(self)
138
+
139
+
140
+ class AsyncRedis(Redis):
141
+ async def execute_command(self, *args, **options):
142
+ await asyncio.sleep(0)
143
+ return reply(args)
144
+
145
+ def pipeline(self, transaction=True, shard_hint=None):
146
+ return AsyncPipeline(self, transaction)
147
+
148
+
149
+ class AsyncPipeline(Pipeline):
150
+ async def immediate_execute_command(self, *args, **options):
151
+ return Pipeline.immediate_execute_command(self, *args, **options)
152
+
153
+ async def execute(self, raise_on_error=True):
154
+ await asyncio.sleep(0)
155
+ return Pipeline.execute(self, raise_on_error)
156
+
157
+ async def reset(self):
158
+ Pipeline.reset(self)
159
+
160
+
161
+ # Detected by their module, as redis-py's own are.
162
+ for _class in (Redis, Pipeline, AsyncRedis, AsyncPipeline):
163
+ _class.__module__ = "redis.client"
164
+
165
+
166
+ class SharedCases(unittest.TestCase):
167
+ def test_every_shared_redis_command_case_comes_out_character_for_character(self):
168
+ with open(CASES, encoding="utf-8") as handle:
169
+ cases = json.load(handle)
170
+ self.assertGreater(len(cases["redis_commands"]), 20)
171
+ for case in cases["redis_commands"]:
172
+ self.assertEqual(q.redis_command(case["args"]), case["expected"], repr(case["args"]))
173
+
174
+ def test_unicode_letters_are_not_ascii_letters(self):
175
+ self.assertIsNone(q.redis_command(["ſet", "k"]))
176
+ self.assertEqual(q.redis_command(["SET", "straße:1"]), "SET ?:?")
177
+ self.assertEqual(q.normalize_statement("xread k", "redis"), "XREAD k")
178
+
179
+
180
+ class Hooks(unittest.TestCase):
181
+ def setUp(self):
182
+ self.platform = make_platform()
183
+ self.queries = self.platform._modules["performance"].queries
184
+ self.queries.rows.clear()
185
+ self.queries.per_minute.clear()
186
+
187
+ def tearDown(self):
188
+ self.queries.rows.clear()
189
+ self.queries.retry = []
190
+ self.platform.close()
191
+
192
+ def redis_rows(self):
193
+ return {key[4]: (row["count"], row["errors"]) for key, row in self.queries.rows.items() if key[3] == "redis"}
194
+
195
+ def test_each_command_is_timed_with_errors_counted_nil_not_and_blocking_ones_left_out(self):
196
+ original = Redis.__dict__["execute_command"]
197
+ redis = Redis()
198
+ self.assertIs(self.platform.watch_database(redis), redis)
199
+ self.assertIs(self.platform.watch_database(redis), redis)
200
+ self.assertEqual(redis.get("user:42:cart"), b"v")
201
+ self.assertEqual(redis.get(b"user:7:cart"), b"v")
202
+ self.assertIsNone(redis.get("missing"))
203
+ with self.assertRaises(ResponseError):
204
+ redis.incr("text")
205
+ redis.client_setname("web-1")
206
+ redis.eval("return 1", 1, "lock:order:9", "hunter2")
207
+ redis.blpop(["jobs"], 0)
208
+ self.assertEqual(self.redis_rows(), {
209
+ "GET user:?:cart": (2, 0), "GET missing": (1, 0), "INCR text": (1, 1),
210
+ "CLIENT SETNAME": (1, 0), "EVAL lock:order:?": (1, 0)})
211
+ self.assertIs(Redis.__dict__["execute_command"], original, "the class is left alone")
212
+
213
+ def test_a_pipeline_or_transaction_is_one_call_named_by_its_first_command(self):
214
+ redis = self.platform.watch_database(Redis())
215
+ pipe = redis.pipeline(transaction=False)
216
+ pipe.get("user:1")
217
+ pipe.incr("user:2")
218
+ self.assertEqual(pipe.execute(), [b"v", b"v"])
219
+ with redis.pipeline() as tx:
220
+ tx.execute_command("SET", "cart:9", "x")
221
+ tx.get("cart:9")
222
+ tx.execute()
223
+ broken = redis.pipeline(transaction=False)
224
+ broken.get("user:3")
225
+ broken.incr("text")
226
+ self.assertIsInstance(broken.execute(raise_on_error=False)[1], ResponseError)
227
+ # An empty one isn't a call.
228
+ redis.pipeline().execute()
229
+ self.assertEqual(self.redis_rows(), {"PIPELINE GET user:?": (2, 1), "MULTI SET cart:?": (1, 0)})
230
+
231
+ def test_a_watch_runs_its_commands_at_once_and_a_called_off_transaction_isnt_a_failure(self):
232
+ redis = self.platform.watch_database(Redis())
233
+ pipe = redis.pipeline()
234
+ pipe.watch("stock:9")
235
+ pipe.get("stock:9")
236
+ pipe.multi()
237
+ pipe.execute_command("SET", "changed", "1")
238
+ with self.assertRaises(WatchError):
239
+ pipe.execute()
240
+ self.assertEqual(self.redis_rows(), {"WATCH stock:?": (1, 0), "GET stock:?": (1, 0), "MULTI SET changed": (1, 0)})
241
+
242
+ def test_asyncio_clients_and_pipelines(self):
243
+ redis = self.platform.watch_database(AsyncRedis())
244
+
245
+ async def run():
246
+ self.assertEqual(await redis.get("user:42"), b"v")
247
+ with self.assertRaises(ResponseError):
248
+ await redis.incr("text")
249
+ pipe = redis.pipeline(transaction=False)
250
+ pipe.get("user:1")
251
+ pipe.get("user:2")
252
+ self.assertEqual(await pipe.execute(), [b"v", b"v"])
253
+
254
+ asyncio.run(run())
255
+ self.assertEqual(self.redis_rows(), {"GET user:?": (1, 0), "INCR text": (1, 1), "PIPELINE GET user:?": (1, 0)})
256
+
257
+ def test_the_packages_own_queue_reads_on_the_same_client_arent_timed(self):
258
+ redis = self.platform.watch_database(Redis())
259
+ queues = self.platform._modules["performance"].queues
260
+
261
+ def read():
262
+ pipe = redis.pipeline(transaction=False)
263
+ pipe.execute_command("LLEN", "rq:queue:default")
264
+ pipe.execute()
265
+ redis.execute_command("HGET", "rq:job:1", "enqueued_at")
266
+ return {"waiting": 0}
267
+
268
+ queues.entry("rq", "localhost:6379/0", "default", read)
269
+ with mock.patch.object(queues.core, "send"):
270
+ queues.sample()
271
+ redis.get("user:1")
272
+ self.assertEqual(self.redis_rows(), {"GET user:?": (1, 0)})
273
+
274
+ def test_djangos_redis_cache_watches_each_client_it_makes(self):
275
+ class RedisCacheClient:
276
+ def __init__(self):
277
+ self._client = Redis
278
+
279
+ def get_client(self, key=None, *, write=False):
280
+ return self._client()
281
+
282
+ class RedisCache:
283
+ def __init__(self):
284
+ self._cache = RedisCacheClient()
285
+
286
+ def get(self, key):
287
+ return self._cache.get_client(key).get(":1:" + key)
288
+
289
+ RedisCache.__module__ = "django.core.cache.backends.redis"
290
+ cache = RedisCache()
291
+ self.assertIs(self.platform.watch_database(cache), cache)
292
+ self.platform.watch_database(cache)
293
+ cache.get("views")
294
+ cache.get("views")
295
+ self.assertEqual(self.redis_rows(), {"GET :?:views": (2, 0)})
296
+
297
+ def test_a_django_cache_made_for_another_thread_is_watched_too(self):
298
+ class RedisCacheClient:
299
+ def get_client(self, key=None, *, write=False):
300
+ return self._client()
301
+
302
+ class RedisCache:
303
+ def __init__(self):
304
+ self._cache = RedisCacheClient()
305
+ self._cache._client = Redis
306
+
307
+ def get(self, key):
308
+ return self._cache.get_client(key).get(":1:" + key)
309
+
310
+ RedisCache.__module__ = "django.core.cache.backends.redis"
311
+
312
+ class CacheHandler:
313
+ """As Django's caches: one cache object per alias per thread."""
314
+ def __init__(self):
315
+ self.local = threading.local()
316
+
317
+ def __iter__(self):
318
+ return iter(["default", "other"])
319
+
320
+ def create_connection(self, alias):
321
+ return RedisCache()
322
+
323
+ def __getitem__(self, alias):
324
+ made = getattr(self.local, alias, None)
325
+ if made is None:
326
+ made = self.create_connection(alias)
327
+ setattr(self.local, alias, made)
328
+ return made
329
+
330
+ caches = CacheHandler()
331
+ module = types.ModuleType("django.core.cache")
332
+ module.caches = caches
333
+ with mock.patch.dict(sys.modules, {"django": types.ModuleType("django"), "django.core": types.ModuleType("django.core"),
334
+ "django.core.cache": module}):
335
+ self.platform.watch_database(caches["default"])
336
+ self.platform.watch_database(caches["default"])
337
+ caches["default"].get("views")
338
+ thread = threading.Thread(target=lambda: (caches["default"].get("views"), caches["other"].get("views")))
339
+ thread.start()
340
+ thread.join()
341
+ self.assertEqual(self.redis_rows(), {"GET :?:views": (2, 0)})
342
+
343
+ def test_a_sync_cluster_transaction_is_multi_and_its_watch_doesnt_name_it(self):
344
+ class TransactionStrategy:
345
+ pass
346
+
347
+ class ClusterPipeline(Redis):
348
+ def __init__(self):
349
+ super().__init__()
350
+ self._execution_strategy = TransactionStrategy()
351
+ self.stack = []
352
+
353
+ def execute_command(self, *args, **kwargs):
354
+ if args[0] != "WATCH":
355
+ self.stack.append(args)
356
+ return self
357
+
358
+ def execute(self, raise_on_error=True):
359
+ stack, self.stack = self.stack, []
360
+ return [reply(args) for args in stack]
361
+
362
+ class RedisCluster(Redis):
363
+ def pipeline(self, transaction=None, shard_hint=None):
364
+ return ClusterPipeline()
365
+
366
+ RedisCluster.__module__ = ClusterPipeline.__module__ = "redis.cluster"
367
+ redis = self.platform.watch_database(RedisCluster())
368
+ pipe = redis.pipeline(transaction=True)
369
+ pipe.execute_command("WATCH", "stock:9")
370
+ pipe.get("stock:9")
371
+ self.assertEqual(pipe.execute(), [b"v"])
372
+ self.assertEqual(self.redis_rows(), {"MULTI GET stock:?": (1, 0)})
373
+
374
+ def test_django_redis_watches_the_clients_it_holds_and_connects(self):
375
+ held = Redis()
376
+
377
+ class DefaultClient:
378
+ def __init__(self):
379
+ self._clients = [held, None]
380
+
381
+ def connect(self, index=0):
382
+ return Redis()
383
+
384
+ def get_client(self, write=True):
385
+ if self._clients[1] is None:
386
+ self._clients[1] = self.connect(1)
387
+ return self._clients[0 if write else 1]
388
+
389
+ class RedisCache:
390
+ client = DefaultClient()
391
+
392
+ RedisCache.__module__ = "django_redis.cache"
393
+ cache = RedisCache()
394
+ self.platform.watch_database(cache)
395
+ cache.client.get_client(write=True).get("session:a1")
396
+ cache.client.get_client(write=False).get("session:b2")
397
+ self.assertEqual(self.redis_rows(), {"GET session:?": (2, 0)})
398
+
399
+ def test_a_django_redis_client_that_isnt_redis_pys_is_handed_back_untouched(self):
400
+ class Custom:
401
+ """A CLIENT_CLASS's connection: no execute_command to watch."""
402
+ __slots__ = ()
403
+
404
+ class DefaultClient:
405
+ _clients = [None]
406
+
407
+ def connect(self, index=0):
408
+ return Custom()
409
+
410
+ class RedisCache:
411
+ client = DefaultClient()
412
+
413
+ RedisCache.__module__ = "django_redis.cache"
414
+ cache = RedisCache()
415
+ self.platform.watch_database(cache)
416
+ self.assertIsInstance(cache.client.connect(0), Custom)
417
+
418
+ def test_with_server_speed_off_nothing_is_timed(self):
419
+ platform = make_platform(performance=False)
420
+ redis = Redis()
421
+ self.assertIs(platform.watch_database(redis), redis)
422
+ redis.get("user:1")
423
+ platform.close()
424
+ self.assertEqual(self.redis_rows(), {})
425
+
426
+
427
+ if __name__ == "__main__":
428
+ unittest.main()