analytics-platform 0.3.7__tar.gz → 0.3.9__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 (55) hide show
  1. {analytics_platform-0.3.7/analytics_platform.egg-info → analytics_platform-0.3.9}/PKG-INFO +54 -20
  2. analytics_platform-0.3.7/PKG-INFO → analytics_platform-0.3.9/README.md +49 -45
  3. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/__init__.py +17 -3
  4. analytics_platform-0.3.9/analytics_platform/_env.py +71 -0
  5. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/core.py +14 -9
  6. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/django.py +14 -6
  7. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/proxy.py +17 -2
  8. analytics_platform-0.3.9/analytics_platform/start.py +118 -0
  9. analytics_platform-0.3.7/README.md → analytics_platform-0.3.9/analytics_platform.egg-info/PKG-INFO +79 -19
  10. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform.egg-info/SOURCES.txt +5 -0
  11. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/pyproject.toml +7 -1
  12. analytics_platform-0.3.9/tests/test_headers.py +28 -0
  13. analytics_platform-0.3.9/tests/test_proxy_answer.py +27 -0
  14. analytics_platform-0.3.9/tests/test_start.py +156 -0
  15. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/LICENSE +0 -0
  16. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/analytics.py +0 -0
  17. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/audit.py +0 -0
  18. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/calls.py +0 -0
  19. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/celery.py +0 -0
  20. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/challenge.py +0 -0
  21. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/crawlers.py +0 -0
  22. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/dramatiq.py +0 -0
  23. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/errors.py +0 -0
  24. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/firewall.py +0 -0
  25. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/flags.py +0 -0
  26. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/forms.py +0 -0
  27. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/inventory.py +0 -0
  28. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/maintenance.py +0 -0
  29. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/performance.py +0 -0
  30. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/posture.py +0 -0
  31. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/queries.py +0 -0
  32. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/queues.py +0 -0
  33. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/request_rules.py +0 -0
  34. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/resources.py +0 -0
  35. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/rq.py +0 -0
  36. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform.egg-info/dependency_links.txt +0 -0
  37. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform.egg-info/top_level.txt +0 -0
  38. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/setup.cfg +0 -0
  39. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/tests/test_calls_queues.py +0 -0
  40. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/tests/test_celery.py +0 -0
  41. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/tests/test_challenge.py +0 -0
  42. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/tests/test_checkin.py +0 -0
  43. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/tests/test_crawlers.py +0 -0
  44. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/tests/test_errors.py +0 -0
  45. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/tests/test_firewall.py +0 -0
  46. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/tests/test_hardening.py +0 -0
  47. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/tests/test_limits.py +0 -0
  48. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/tests/test_page_limits.py +0 -0
  49. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/tests/test_proxy.py +0 -0
  50. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/tests/test_queries.py +0 -0
  51. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/tests/test_redis.py +0 -0
  52. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/tests/test_request_rules.py +0 -0
  53. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/tests/test_resources.py +0 -0
  54. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/tests/test_rq_dramatiq.py +0 -0
  55. {analytics_platform-0.3.7 → analytics_platform-0.3.9}/tests/test_static_late.py +0 -0
@@ -1,9 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: analytics-platform
3
- Version: 0.3.7
3
+ Version: 0.3.9
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
7
+ Project-URL: Homepage, https://sitegoose.com
8
+ Project-URL: Source, https://github.com/StevieC87/Sitegoose-Packages/tree/main/platform-python
9
+ Project-URL: Issues, https://github.com/StevieC87/Sitegoose-Packages/issues
10
+ Project-URL: Security, https://sitegoose.com/security
7
11
  Keywords: firewall,waf,rate-limit,error-monitoring,analytics,server-monitoring,django,flask,fastapi,celery,rq,dramatiq,wsgi,asgi
8
12
  Classifier: Development Status :: 4 - Beta
9
13
  Classifier: Intended Audience :: Developers
@@ -35,7 +39,42 @@ dependencies; Python 3.8 or later.
35
39
  pip install analytics-platform
36
40
  ```
37
41
 
38
- ## Django
42
+ Put your keys in `.env` (site settings has all three, ready to copy), or in
43
+ your host's environment:
44
+
45
+ ```bash
46
+ ANALYTICS_SITE_ID=YOUR_SITE_ID
47
+ ANALYTICS_SITE_SECRET=YOUR_SITE_SECRET
48
+ ANALYTICS_ENDPOINT=https://YOUR_COLLECTOR_URL
49
+ ```
50
+
51
+ The package reads only the `ANALYTICS_*` lines of the `.env` in the folder the
52
+ server starts in (Django: beside `manage.py`), and never writes them into
53
+ `os.environ`, so your own `.env` loading is unaffected. Behind Cloudflare, add
54
+ `ANALYTICS_TRUST_PROXY=cloudflare`; behind Nginx or a load balancer,
55
+ `ANALYTICS_TRUST_PROXY=1`. Several services on one site? Give each
56
+ `ANALYTICS_SERVER_NAME=api` (or `worker`, and so on).
57
+
58
+ ## Flask and FastAPI: one line
59
+
60
+ ```python
61
+ from analytics_platform.start import platform
62
+ ```
63
+
64
+ Put it near the top of the module that makes your app. On the app's first
65
+ request, the package puts itself in front of it: Flask through
66
+ `init_flask(app)`, and FastAPI or any Starlette app through its middleware, as
67
+ the outermost of yours. It works whether the import is above or below
68
+ `app = Flask(...)`. An app that already has the platform by hand gets nothing
69
+ added a second time, and a FastAPI app mounted inside another isn't counted
70
+ twice.
71
+
72
+ Use the same import wherever you call `platform.track()` and the rest: it's
73
+ one platform for the whole process. It's made on first use, not on import, so
74
+ under Gunicorn or uWSGI with `--preload` its background threads start in each
75
+ worker, not in the master.
76
+
77
+ ## Django: one line in settings
39
78
 
40
79
  ```python
41
80
  # settings.py
@@ -43,36 +82,31 @@ MIDDLEWARE = [
43
82
  "analytics_platform.django.PlatformMiddleware", # first
44
83
  # ...
45
84
  ]
85
+ ```
46
86
 
47
- ANALYTICS_PLATFORM = {
48
- "site_id": "YOUR_SITE_ID",
49
- "secret": os.environ["ANALYTICS_SITE_SECRET"], # site settings; keep it on the server
50
- "endpoint": "https://YOUR_COLLECTOR_URL",
51
- # "trust_proxy": 1, # behind Nginx or a load balancer
52
- # "trust_proxy": "cloudflare", # every request through Cloudflare
53
- }
87
+ That's all with the keys in `.env` or the environment. Options in code go in
88
+ `ANALYTICS_PLATFORM`, and win over the environment:
89
+
90
+ ```python
91
+ ANALYTICS_PLATFORM = {"trust_proxy": 1, "errors": {"capture_logging": True}}
54
92
  ```
55
93
 
56
94
  In your code: `from analytics_platform.django import get_platform`. The
57
95
  package tells the collector it is a Django site (`analytics-platform-django/…`),
58
96
  so attack patterns written for Django reach it.
59
97
 
60
- ## FastAPI / Starlette
98
+ ## Setting it up in code instead
99
+
100
+ For options in code with Flask, FastAPI or another app (use this or the line
101
+ above, not both). Any key left out is read from `ANALYTICS_*`:
61
102
 
62
103
  ```python
63
104
  from analytics_platform import create_platform, PlatformMiddleware
64
105
 
65
- platform = create_platform(site_id="YOUR_SITE_ID", secret=os.environ["ANALYTICS_SITE_SECRET"],
66
- endpoint="https://YOUR_COLLECTOR_URL")
67
- app.add_middleware(PlatformMiddleware, platform=platform)
68
- ```
69
-
70
- ## Flask
106
+ platform = create_platform(errors={"capture_logging": True})
71
107
 
72
- ```python
73
- platform = create_platform(site_id="YOUR_SITE_ID", secret=os.environ["ANALYTICS_SITE_SECRET"],
74
- endpoint="https://YOUR_COLLECTOR_URL")
75
- platform.init_flask(app)
108
+ app.add_middleware(PlatformMiddleware, platform=platform) # FastAPI / Starlette
109
+ platform.init_flask(app) # Flask
76
110
  ```
77
111
 
78
112
  Other WSGI apps: `app = platform.wsgi(app)`; other ASGI apps: `app = platform.asgi(app)`.
@@ -1,29 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: analytics-platform
3
- Version: 0.3.7
4
- Summary: Firewall, error monitoring and server analytics for your Python server, run from your analytics dashboard.
5
- Author: steviec87
6
- License-Expression: MIT
7
- Keywords: firewall,waf,rate-limit,error-monitoring,analytics,server-monitoring,django,flask,fastapi,celery,rq,dramatiq,wsgi,asgi
8
- Classifier: Development Status :: 4 - Beta
9
- Classifier: Intended Audience :: Developers
10
- Classifier: Intended Audience :: System Administrators
11
- Classifier: Operating System :: OS Independent
12
- Classifier: Programming Language :: Python :: 3
13
- Classifier: Programming Language :: Python :: 3.8
14
- Classifier: Programming Language :: Python :: 3.12
15
- Classifier: Framework :: Django
16
- Classifier: Framework :: Flask
17
- Classifier: Framework :: FastAPI
18
- Classifier: Topic :: Internet :: WWW/HTTP
19
- Classifier: Topic :: Internet :: Log Analysis
20
- Classifier: Topic :: Security
21
- Classifier: Topic :: System :: Monitoring
22
- Requires-Python: >=3.8
23
- Description-Content-Type: text/markdown
24
- License-File: LICENSE
25
- Dynamic: license-file
26
-
27
1
  # analytics-platform (Python)
28
2
 
29
3
  One package for your Python server, run from your analytics dashboard: a
@@ -35,7 +9,42 @@ dependencies; Python 3.8 or later.
35
9
  pip install analytics-platform
36
10
  ```
37
11
 
38
- ## Django
12
+ Put your keys in `.env` (site settings has all three, ready to copy), or in
13
+ your host's environment:
14
+
15
+ ```bash
16
+ ANALYTICS_SITE_ID=YOUR_SITE_ID
17
+ ANALYTICS_SITE_SECRET=YOUR_SITE_SECRET
18
+ ANALYTICS_ENDPOINT=https://YOUR_COLLECTOR_URL
19
+ ```
20
+
21
+ The package reads only the `ANALYTICS_*` lines of the `.env` in the folder the
22
+ server starts in (Django: beside `manage.py`), and never writes them into
23
+ `os.environ`, so your own `.env` loading is unaffected. Behind Cloudflare, add
24
+ `ANALYTICS_TRUST_PROXY=cloudflare`; behind Nginx or a load balancer,
25
+ `ANALYTICS_TRUST_PROXY=1`. Several services on one site? Give each
26
+ `ANALYTICS_SERVER_NAME=api` (or `worker`, and so on).
27
+
28
+ ## Flask and FastAPI: one line
29
+
30
+ ```python
31
+ from analytics_platform.start import platform
32
+ ```
33
+
34
+ Put it near the top of the module that makes your app. On the app's first
35
+ request, the package puts itself in front of it: Flask through
36
+ `init_flask(app)`, and FastAPI or any Starlette app through its middleware, as
37
+ the outermost of yours. It works whether the import is above or below
38
+ `app = Flask(...)`. An app that already has the platform by hand gets nothing
39
+ added a second time, and a FastAPI app mounted inside another isn't counted
40
+ twice.
41
+
42
+ Use the same import wherever you call `platform.track()` and the rest: it's
43
+ one platform for the whole process. It's made on first use, not on import, so
44
+ under Gunicorn or uWSGI with `--preload` its background threads start in each
45
+ worker, not in the master.
46
+
47
+ ## Django: one line in settings
39
48
 
40
49
  ```python
41
50
  # settings.py
@@ -43,36 +52,31 @@ MIDDLEWARE = [
43
52
  "analytics_platform.django.PlatformMiddleware", # first
44
53
  # ...
45
54
  ]
55
+ ```
46
56
 
47
- ANALYTICS_PLATFORM = {
48
- "site_id": "YOUR_SITE_ID",
49
- "secret": os.environ["ANALYTICS_SITE_SECRET"], # site settings; keep it on the server
50
- "endpoint": "https://YOUR_COLLECTOR_URL",
51
- # "trust_proxy": 1, # behind Nginx or a load balancer
52
- # "trust_proxy": "cloudflare", # every request through Cloudflare
53
- }
57
+ That's all with the keys in `.env` or the environment. Options in code go in
58
+ `ANALYTICS_PLATFORM`, and win over the environment:
59
+
60
+ ```python
61
+ ANALYTICS_PLATFORM = {"trust_proxy": 1, "errors": {"capture_logging": True}}
54
62
  ```
55
63
 
56
64
  In your code: `from analytics_platform.django import get_platform`. The
57
65
  package tells the collector it is a Django site (`analytics-platform-django/…`),
58
66
  so attack patterns written for Django reach it.
59
67
 
60
- ## FastAPI / Starlette
68
+ ## Setting it up in code instead
69
+
70
+ For options in code with Flask, FastAPI or another app (use this or the line
71
+ above, not both). Any key left out is read from `ANALYTICS_*`:
61
72
 
62
73
  ```python
63
74
  from analytics_platform import create_platform, PlatformMiddleware
64
75
 
65
- platform = create_platform(site_id="YOUR_SITE_ID", secret=os.environ["ANALYTICS_SITE_SECRET"],
66
- endpoint="https://YOUR_COLLECTOR_URL")
67
- app.add_middleware(PlatformMiddleware, platform=platform)
68
- ```
76
+ platform = create_platform(errors={"capture_logging": True})
69
77
 
70
- ## Flask
71
-
72
- ```python
73
- platform = create_platform(site_id="YOUR_SITE_ID", secret=os.environ["ANALYTICS_SITE_SECRET"],
74
- endpoint="https://YOUR_COLLECTOR_URL")
75
- platform.init_flask(app)
78
+ app.add_middleware(PlatformMiddleware, platform=platform) # FastAPI / Starlette
79
+ platform.init_flask(app) # Flask
76
80
  ```
77
81
 
78
82
  Other WSGI apps: `app = platform.wsgi(app)`; other ASGI apps: `app = platform.asgi(app)`.
@@ -20,6 +20,7 @@ import http
20
20
  import time
21
21
  import weakref
22
22
 
23
+ from . import _env
23
24
  from .core import Core, EXIT_DEADLINE, SESSION_HEADER, VERSION, begin_exit, header, request_target, within
24
25
  from .errors import add_breadcrumb, current_request, current_trail
25
26
  from .queries import current_queries
@@ -77,7 +78,8 @@ MODULES = ("proxy", "firewall", "errors", "analytics", "performance", "crawlers"
77
78
 
78
79
  class Platform:
79
80
  """site_id, secret (site settings; keep it on the server), endpoint (the
80
- collector's URL); trust_proxy (how to find the visitor's address: None
81
+ collector's URL), each read from ANALYTICS_SITE_ID, ANALYTICS_SITE_SECRET and
82
+ ANALYTICS_ENDPOINT when left out; trust_proxy (ANALYTICS_TRUST_PROXY) (how to find the visitor's address: None
81
83
  uses the connection's, a number of proxies in X-Forwarded-For,
82
84
  "cloudflare"); debug (print send problems); per module True / False / {options}.
83
85
  client: the name this package goes by to the collector ("python", or
@@ -86,14 +88,20 @@ class Platform:
86
88
  keep Server speed and leave database queries, outgoing calls or job
87
89
  queues out of it."""
88
90
 
89
- def __init__(self, site_id, secret, endpoint, trust_proxy=None, debug=False, firewall=True, errors=True, analytics=True,
91
+ def __init__(self, site_id=None, secret=None, endpoint=None, trust_proxy=None, debug=False, firewall=True, errors=True, analytics=True,
90
92
  performance=True, crawlers=True, forms=True, headers=True, audit=True, flags=True, maintenance=True,
91
93
  inventory=True,
92
94
  posture=True, resources=True, proxy=True, server_name=None, client="python", queries=True,
93
95
  calls=True, queues=True):
96
+ # Each of these left out is read from ANALYTICS_* in the environment (_env.py).
97
+ given = _env.from_environment({"site_id": site_id, "secret": secret, "endpoint": endpoint,
98
+ "trust_proxy": trust_proxy, "server_name": server_name})
99
+ site_id, secret, endpoint = given["site_id"], given["secret"], given["endpoint"]
100
+ trust_proxy, server_name = given["trust_proxy"], given["server_name"]
94
101
  self.core = Core(site_id, secret, endpoint, debug, server_name=server_name, client=client)
95
102
  if not self.core.valid:
96
- print("[analytics-platform] site_id, secret and endpoint are required; nothing is sent and every request passes")
103
+ print("[analytics-platform] site_id, secret and endpoint are required (or ANALYTICS_SITE_ID, ANALYTICS_SITE_SECRET "
104
+ "and ANALYTICS_ENDPOINT in the environment); nothing is sent and every request passes")
97
105
  settings = {"firewall": firewall, "errors": errors, "analytics": analytics, "performance": performance,
98
106
  "crawlers": crawlers, "forms": forms,
99
107
  "headers": headers, "audit": audit, "flags": flags, "maintenance": maintenance, "inventory": inventory,
@@ -577,6 +585,12 @@ class Platform:
577
585
 
578
586
  def init_flask(self, app):
579
587
  """Flask: the firewall in front of the app, and its request errors."""
588
+ # Once per app, by hand or by analytics_platform.start.
589
+ extensions = getattr(app, "extensions", None)
590
+ if isinstance(extensions, dict):
591
+ if "analytics_platform" in extensions:
592
+ return app
593
+ extensions["analytics_platform"] = self
580
594
  app.wsgi_app = self.wsgi(app.wsgi_app, timing=False)
581
595
  from flask import g, got_request_exception, request
582
596
 
@@ -0,0 +1,71 @@
1
+ """The options read from ANALYTICS_* when they aren't passed, as the Node and
2
+ Go packages do: ANALYTICS_SITE_ID, ANALYTICS_SITE_SECRET, ANALYTICS_ENDPOINT,
3
+ and optionally ANALYTICS_TRUST_PROXY ("cloudflare" or a number of proxies)
4
+ and ANALYTICS_SERVER_NAME. And the ANALYTICS_* lines of a .env file, for
5
+ analytics_platform.start and Django: read, never written to os.environ, so
6
+ whatever loads the app's own .env files still sets them as it would."""
7
+ import os
8
+ import re
9
+
10
+ _LINE = re.compile(r"^\s*(?:export\s+)?(ANALYTICS_[A-Z0-9_]+)\s*=\s*(.*)$")
11
+
12
+
13
+ def options_from(env):
14
+ """{site_id, secret, endpoint, trust_proxy, server_name} found in env."""
15
+ def read(name):
16
+ value = env.get(name)
17
+ return value.strip() if isinstance(value, str) and value.strip() else None
18
+
19
+ found = {}
20
+ for option, name in (("site_id", "ANALYTICS_SITE_ID"), ("secret", "ANALYTICS_SITE_SECRET"),
21
+ ("endpoint", "ANALYTICS_ENDPOINT"), ("server_name", "ANALYTICS_SERVER_NAME")):
22
+ if read(name):
23
+ found[option] = read(name)
24
+ if "endpoint" not in found and read("ANALYTICS_FIREWALL_ENDPOINT"):
25
+ found["endpoint"] = read("ANALYTICS_FIREWALL_ENDPOINT")
26
+ trust = read("ANALYTICS_TRUST_PROXY")
27
+ if trust:
28
+ if trust.lower() == "cloudflare":
29
+ found["trust_proxy"] = "cloudflare"
30
+ elif trust.lower() == "true":
31
+ found["trust_proxy"] = 1
32
+ elif trust.isdigit() and int(trust) > 0:
33
+ found["trust_proxy"] = int(trust)
34
+ return found
35
+
36
+
37
+ def read_dotenv(path):
38
+ """The ANALYTICS_* lines of a .env file as {name: value}; {} without one.
39
+ Not a dotenv: no expansion, and every other line is ignored."""
40
+ try:
41
+ with open(path, encoding="utf-8") as handle:
42
+ text = handle.read()
43
+ except (OSError, UnicodeDecodeError):
44
+ return {}
45
+ found = {}
46
+ for line in text.splitlines():
47
+ match = _LINE.match(line)
48
+ if not match:
49
+ continue
50
+ value = match.group(2).strip()
51
+ quote = value[:1]
52
+ if quote in ("'", '"') and value.find(quote, 1) > 0:
53
+ value = value[1:value.find(quote, 1)]
54
+ else:
55
+ value = re.sub(r"\s+#.*$", "", value)
56
+ found[match.group(1)] = value
57
+ return found
58
+
59
+
60
+ def from_environment(options, dotenv=None):
61
+ """options with what's missing filled from os.environ, then (when given)
62
+ from a .env file's ANALYTICS_* lines."""
63
+ filled = dict(options)
64
+ sources = [options_from(os.environ)]
65
+ if dotenv:
66
+ sources.append(options_from(read_dotenv(dotenv)))
67
+ for source in sources:
68
+ for key, value in source.items():
69
+ if filled.get(key) is None:
70
+ filled[key] = value
71
+ return filled
@@ -23,7 +23,7 @@ import urllib.error
23
23
  import urllib.parse
24
24
  import urllib.request
25
25
 
26
- VERSION = "0.3.7"
26
+ VERSION = "0.3.9"
27
27
  TIMEOUT = 5
28
28
  MIN_SYNC = 30
29
29
  RETRY = 60
@@ -285,19 +285,24 @@ CLIENTS = ("python", "django")
285
285
 
286
286
 
287
287
 
288
- # A dashboard header is added only when it is well formed: a token name that
289
- # isn't one of the headers that frame the response (a Content-Length or
290
- # Transfer-Encoding of ours would desync it, a Set-Cookie or Content-Type
291
- # would change what it is), and a value of visible ASCII, spaces and tabs,
292
- # so no line break, control character or byte a server rejects gets through.
293
- FRAMING = frozenset(("content-length", "transfer-encoding", "connection", "set-cookie", "content-type"))
294
- _HEADER_NAME = re.compile(r"[A-Za-z][A-Za-z0-9-]{0,63}")
288
+ # A dashboard header is added only when it is one of the nine the dashboard
289
+ # can produce (expresscollect/Analytics/platform.js, headersOf), in any case,
290
+ # with a value of visible ASCII, spaces and tabs, so no line break, control
291
+ # character or byte a server rejects gets through. A list of what's allowed,
292
+ # not of what isn't: whoever got into our servers still couldn't make this
293
+ # app grant another site its users' data (Access-Control-Allow-*), send every
294
+ # page elsewhere (Refresh), log everyone out (Clear-Site-Data) or change what
295
+ # a response is. Shared cases: sdk/protocols/header-cases.json.
296
+ SECURITY_HEADERS = frozenset((
297
+ "strict-transport-security", "x-content-type-options", "x-frame-options", "referrer-policy",
298
+ "permissions-policy", "cross-origin-opener-policy", "content-security-policy",
299
+ "content-security-policy-report-only", "reporting-endpoints"))
295
300
  _HEADER_VALUE = re.compile(r"[\x20-\x7e\t]*")
296
301
 
297
302
 
298
303
  def safe_header(pair):
299
304
  return (isinstance(pair, (list, tuple)) and len(pair) == 2 and isinstance(pair[0], str)
300
- and _HEADER_NAME.fullmatch(pair[0]) is not None and pair[0].lower() not in FRAMING
305
+ and pair[0].lower() in SECURITY_HEADERS
301
306
  and isinstance(pair[1], str) and len(pair[1]) <= 4000 and _HEADER_VALUE.fullmatch(pair[1]) is not None)
302
307
 
303
308
  class Core:
@@ -1,11 +1,12 @@
1
- """Django: add the middleware first in MIDDLEWARE and the options to settings.
1
+ """Django: add the middleware first in MIDDLEWARE. That's all when the keys
2
+ are in the environment or in the project's .env (beside manage.py):
3
+ ANALYTICS_SITE_ID, ANALYTICS_SITE_SECRET and ANALYTICS_ENDPOINT.
2
4
 
3
5
  MIDDLEWARE = ["analytics_platform.django.PlatformMiddleware", ...]
4
- ANALYTICS_PLATFORM = {
5
- "site_id": "...",
6
- "secret": os.environ["ANALYTICS_SITE_SECRET"],
7
- "endpoint": "https://...",
8
- }
6
+
7
+ Options in code go in settings, and win over the environment:
8
+
9
+ ANALYTICS_PLATFORM = {"trust_proxy": 1, "errors": {"capture_logging": True}}
9
10
 
10
11
  In your code, get_platform() is the same platform: get_platform().track(...).
11
12
  The visitor's address is REMOTE_ADDR unless you set trust_proxy. The package
@@ -13,11 +14,13 @@ says it is Django (analytics-platform-django/<version>), so request rules
13
14
  aimed at Django sites reach it. Every connection's queries are timed for
14
15
  Server speed, under the request that ran them ("queries": False leaves them out).
15
16
  """
17
+ import os
16
18
  import time
17
19
 
18
20
  from django.conf import settings
19
21
  from django.http import HttpResponse
20
22
 
23
+ from . import _env
21
24
  from . import SESSION_HEADER, Platform, _inflight_end, _inflight_start, _queries_end, _queries_start
22
25
  from .errors import current_request, current_trail
23
26
 
@@ -27,8 +30,13 @@ _platform = None
27
30
  def get_platform():
28
31
  global _platform
29
32
  if _platform is None:
33
+ # Settings first, then ANALYTICS_* in the environment, then the
34
+ # ANALYTICS_* lines of the project's .env (beside manage.py), which is
35
+ # read, never loaded into the environment.
30
36
  options = dict(getattr(settings, "ANALYTICS_PLATFORM", {}))
31
37
  options.setdefault("client", "django")
38
+ base = getattr(settings, "BASE_DIR", None) or os.getcwd()
39
+ options = _env.from_environment(options, os.path.join(str(base), ".env"))
32
40
  _platform = Platform(**options)
33
41
  _connect_login_failed()
34
42
  _watch_queries(_platform)
@@ -49,6 +49,21 @@ PASS_HEADERS = ("content-type", "user-agent", "accept-language", "referer", "ori
49
49
  "sec-fetch-site", "sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform")
50
50
  SAFE_PATH = re.compile(r"[A-Za-z0-9/_.-]{1,120}\Z")
51
51
  FILE_PATH = re.compile(r"v(\d+(?:\.\d+\.\d+)?)/([a-z]+\.js)\Z")
52
+ # What a collector answer may be served as from the site's own domain. Its
53
+ # type comes from the collector, so anything but these three becomes plain
54
+ # text, and nosniff stops a browser guessing: a server that is no longer ours
55
+ # still can't answer <prefix>/api/... with a page that runs as the customer's
56
+ # own site. Shared cases: sdk/protocols/proxy-answer-cases.json.
57
+ ANSWER_TYPES = frozenset(("application/json", "text/plain", "image/webp"))
58
+ _VISIBLE = re.compile(r"[\x20-\x7e]*")
59
+
60
+
61
+ def answer_type(value):
62
+ value = value if isinstance(value, str) else ""
63
+ kind = value.split(";", 1)[0].strip().lower()
64
+ return value if kind in ANSWER_TYPES and _VISIBLE.fullmatch(value) else "text/plain; charset=utf-8"
65
+
66
+
52
67
  NOT_FOUND = (404, [("Content-Type", "text/plain; charset=utf-8")], b"Not found")
53
68
 
54
69
 
@@ -236,8 +251,8 @@ class Proxy:
236
251
 
237
252
  @staticmethod
238
253
  def _answer_headers(headers):
239
- out = []
240
- for name in ("Content-Type", "Cache-Control", "Retry-After"):
254
+ out = [("Content-Type", answer_type(headers.get("Content-Type"))), ("X-Content-Type-Options", "nosniff")]
255
+ for name in ("Cache-Control", "Retry-After"):
241
256
  value = headers.get(name)
242
257
  if value:
243
258
  out.append((name, value))
@@ -0,0 +1,118 @@
1
+ """The one-line setup. Near the top of the module that makes your app:
2
+
3
+ from analytics_platform.start import platform
4
+
5
+ It reads ANALYTICS_SITE_ID, ANALYTICS_SITE_SECRET and ANALYTICS_ENDPOINT from
6
+ the environment, or else from the ANALYTICS_* lines of the .env in the folder
7
+ the server starts in (read, never written to os.environ).
8
+
9
+ Flask and FastAPI (any Starlette app): that's all. On an app's first request
10
+ it puts itself in front, as platform.init_flask(app) and
11
+ app.add_middleware(PlatformMiddleware, platform=platform) do by hand, so it
12
+ doesn't matter whether this import comes before or after the app is made. An
13
+ app that already has either by hand gets nothing added a second time, and a
14
+ Starlette app mounted inside another isn't counted twice.
15
+
16
+ Django: add "analytics_platform.django.PlatformMiddleware" first in
17
+ MIDDLEWARE instead, and use get_platform() from there.
18
+
19
+ The platform is made on first use (the first request, or the first
20
+ platform.track()), not on import: under Gunicorn or uWSGI with the app loaded
21
+ before the workers fork, its background threads then start in each worker,
22
+ not in the master. Use the same import wherever you call platform.track() and
23
+ the rest: it's one platform for the process.
24
+ """
25
+ import os
26
+ import threading
27
+
28
+ from . import Platform, PlatformMiddleware, _env
29
+
30
+ _lock = threading.Lock()
31
+ _made = None
32
+
33
+
34
+ def _platform():
35
+ global _made
36
+ if _made is None:
37
+ with _lock:
38
+ if _made is None:
39
+ _made = Platform(**_env.from_environment({}, os.path.join(os.getcwd(), ".env")))
40
+ return _made
41
+
42
+
43
+ class _Platform:
44
+ """Stands for the platform until its first use, then hands every
45
+ attribute to it."""
46
+
47
+ def __getattr__(self, name):
48
+ return getattr(_platform(), name)
49
+
50
+ def __repr__(self):
51
+ return "<analytics_platform.start platform%s>" % ("" if _made is not None else " (not made yet)")
52
+
53
+
54
+ platform = _Platform()
55
+
56
+ _MARK = "_analytics_platform_started"
57
+
58
+
59
+ def _hook_flask():
60
+ try:
61
+ from flask import Flask
62
+ except Exception:
63
+ return
64
+ if getattr(Flask.__call__, _MARK, False):
65
+ return
66
+ original = Flask.__call__
67
+ hook_lock = threading.Lock()
68
+
69
+ # The first request's own thread hooks the app in before Flask marks it
70
+ # as started (after that it refuses new before_request functions); other
71
+ # threads wait for it, so none gets past without it.
72
+ def __call__(self, environ, start_response):
73
+ if not self.__dict__.get(_MARK):
74
+ with hook_lock:
75
+ if not self.__dict__.get(_MARK):
76
+ try:
77
+ _platform().init_flask(self)
78
+ except Exception as error:
79
+ print("[analytics-platform] couldn't add itself to this Flask app; call platform.init_flask(app) by hand: %s" % error)
80
+ self.__dict__[_MARK] = True
81
+ return original(self, environ, start_response)
82
+
83
+ setattr(__call__, _MARK, True)
84
+ Flask.__call__ = __call__
85
+
86
+
87
+ def _hook_starlette():
88
+ try:
89
+ from starlette.applications import Starlette
90
+ from starlette.middleware import Middleware
91
+ except Exception:
92
+ return
93
+ if getattr(Starlette.__call__, _MARK, False):
94
+ return
95
+ original = Starlette.__call__
96
+
97
+ # Before the app builds its middleware stack, on its first call. Only the
98
+ # outermost app: a mounted one is called with scope["app"] already set by
99
+ # the app it's mounted in. Outermost of your own middleware, as
100
+ # add_middleware() called last puts it.
101
+ async def __call__(self, scope, receive, send):
102
+ if not self.__dict__.get(_MARK):
103
+ self.__dict__[_MARK] = True
104
+ outermost = "app" not in scope
105
+ by_hand = any(getattr(m, "cls", None) is PlatformMiddleware for m in getattr(self, "user_middleware", []))
106
+ if outermost and not by_hand and getattr(self, "middleware_stack", None) is None:
107
+ self.user_middleware.insert(0, Middleware(PlatformMiddleware, platform=platform))
108
+ await original(self, scope, receive, send)
109
+
110
+ setattr(__call__, _MARK, True)
111
+ Starlette.__call__ = __call__
112
+
113
+
114
+ # Each only when its framework is installed.
115
+ _hook_flask()
116
+ _hook_starlette()
117
+
118
+ __all__ = ["platform"]
@@ -1,3 +1,33 @@
1
+ Metadata-Version: 2.4
2
+ Name: analytics-platform
3
+ Version: 0.3.9
4
+ Summary: Firewall, error monitoring and server analytics for your Python server, run from your analytics dashboard.
5
+ Author: steviec87
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://sitegoose.com
8
+ Project-URL: Source, https://github.com/StevieC87/Sitegoose-Packages/tree/main/platform-python
9
+ Project-URL: Issues, https://github.com/StevieC87/Sitegoose-Packages/issues
10
+ Project-URL: Security, https://sitegoose.com/security
11
+ Keywords: firewall,waf,rate-limit,error-monitoring,analytics,server-monitoring,django,flask,fastapi,celery,rq,dramatiq,wsgi,asgi
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: System Administrators
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.8
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Framework :: Django
20
+ Classifier: Framework :: Flask
21
+ Classifier: Framework :: FastAPI
22
+ Classifier: Topic :: Internet :: WWW/HTTP
23
+ Classifier: Topic :: Internet :: Log Analysis
24
+ Classifier: Topic :: Security
25
+ Classifier: Topic :: System :: Monitoring
26
+ Requires-Python: >=3.8
27
+ Description-Content-Type: text/markdown
28
+ License-File: LICENSE
29
+ Dynamic: license-file
30
+
1
31
  # analytics-platform (Python)
2
32
 
3
33
  One package for your Python server, run from your analytics dashboard: a
@@ -9,7 +39,42 @@ dependencies; Python 3.8 or later.
9
39
  pip install analytics-platform
10
40
  ```
11
41
 
12
- ## Django
42
+ Put your keys in `.env` (site settings has all three, ready to copy), or in
43
+ your host's environment:
44
+
45
+ ```bash
46
+ ANALYTICS_SITE_ID=YOUR_SITE_ID
47
+ ANALYTICS_SITE_SECRET=YOUR_SITE_SECRET
48
+ ANALYTICS_ENDPOINT=https://YOUR_COLLECTOR_URL
49
+ ```
50
+
51
+ The package reads only the `ANALYTICS_*` lines of the `.env` in the folder the
52
+ server starts in (Django: beside `manage.py`), and never writes them into
53
+ `os.environ`, so your own `.env` loading is unaffected. Behind Cloudflare, add
54
+ `ANALYTICS_TRUST_PROXY=cloudflare`; behind Nginx or a load balancer,
55
+ `ANALYTICS_TRUST_PROXY=1`. Several services on one site? Give each
56
+ `ANALYTICS_SERVER_NAME=api` (or `worker`, and so on).
57
+
58
+ ## Flask and FastAPI: one line
59
+
60
+ ```python
61
+ from analytics_platform.start import platform
62
+ ```
63
+
64
+ Put it near the top of the module that makes your app. On the app's first
65
+ request, the package puts itself in front of it: Flask through
66
+ `init_flask(app)`, and FastAPI or any Starlette app through its middleware, as
67
+ the outermost of yours. It works whether the import is above or below
68
+ `app = Flask(...)`. An app that already has the platform by hand gets nothing
69
+ added a second time, and a FastAPI app mounted inside another isn't counted
70
+ twice.
71
+
72
+ Use the same import wherever you call `platform.track()` and the rest: it's
73
+ one platform for the whole process. It's made on first use, not on import, so
74
+ under Gunicorn or uWSGI with `--preload` its background threads start in each
75
+ worker, not in the master.
76
+
77
+ ## Django: one line in settings
13
78
 
14
79
  ```python
15
80
  # settings.py
@@ -17,36 +82,31 @@ MIDDLEWARE = [
17
82
  "analytics_platform.django.PlatformMiddleware", # first
18
83
  # ...
19
84
  ]
85
+ ```
20
86
 
21
- ANALYTICS_PLATFORM = {
22
- "site_id": "YOUR_SITE_ID",
23
- "secret": os.environ["ANALYTICS_SITE_SECRET"], # site settings; keep it on the server
24
- "endpoint": "https://YOUR_COLLECTOR_URL",
25
- # "trust_proxy": 1, # behind Nginx or a load balancer
26
- # "trust_proxy": "cloudflare", # every request through Cloudflare
27
- }
87
+ That's all with the keys in `.env` or the environment. Options in code go in
88
+ `ANALYTICS_PLATFORM`, and win over the environment:
89
+
90
+ ```python
91
+ ANALYTICS_PLATFORM = {"trust_proxy": 1, "errors": {"capture_logging": True}}
28
92
  ```
29
93
 
30
94
  In your code: `from analytics_platform.django import get_platform`. The
31
95
  package tells the collector it is a Django site (`analytics-platform-django/…`),
32
96
  so attack patterns written for Django reach it.
33
97
 
34
- ## FastAPI / Starlette
98
+ ## Setting it up in code instead
99
+
100
+ For options in code with Flask, FastAPI or another app (use this or the line
101
+ above, not both). Any key left out is read from `ANALYTICS_*`:
35
102
 
36
103
  ```python
37
104
  from analytics_platform import create_platform, PlatformMiddleware
38
105
 
39
- platform = create_platform(site_id="YOUR_SITE_ID", secret=os.environ["ANALYTICS_SITE_SECRET"],
40
- endpoint="https://YOUR_COLLECTOR_URL")
41
- app.add_middleware(PlatformMiddleware, platform=platform)
42
- ```
106
+ platform = create_platform(errors={"capture_logging": True})
43
107
 
44
- ## Flask
45
-
46
- ```python
47
- platform = create_platform(site_id="YOUR_SITE_ID", secret=os.environ["ANALYTICS_SITE_SECRET"],
48
- endpoint="https://YOUR_COLLECTOR_URL")
49
- platform.init_flask(app)
108
+ app.add_middleware(PlatformMiddleware, platform=platform) # FastAPI / Starlette
109
+ platform.init_flask(app) # Flask
50
110
  ```
51
111
 
52
112
  Other WSGI apps: `app = platform.wsgi(app)`; other ASGI apps: `app = platform.asgi(app)`.
@@ -2,6 +2,7 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  analytics_platform/__init__.py
5
+ analytics_platform/_env.py
5
6
  analytics_platform/analytics.py
6
7
  analytics_platform/audit.py
7
8
  analytics_platform/calls.py
@@ -25,6 +26,7 @@ analytics_platform/queues.py
25
26
  analytics_platform/request_rules.py
26
27
  analytics_platform/resources.py
27
28
  analytics_platform/rq.py
29
+ analytics_platform/start.py
28
30
  analytics_platform.egg-info/PKG-INFO
29
31
  analytics_platform.egg-info/SOURCES.txt
30
32
  analytics_platform.egg-info/dependency_links.txt
@@ -37,12 +39,15 @@ tests/test_crawlers.py
37
39
  tests/test_errors.py
38
40
  tests/test_firewall.py
39
41
  tests/test_hardening.py
42
+ tests/test_headers.py
40
43
  tests/test_limits.py
41
44
  tests/test_page_limits.py
42
45
  tests/test_proxy.py
46
+ tests/test_proxy_answer.py
43
47
  tests/test_queries.py
44
48
  tests/test_redis.py
45
49
  tests/test_request_rules.py
46
50
  tests/test_resources.py
47
51
  tests/test_rq_dramatiq.py
52
+ tests/test_start.py
48
53
  tests/test_static_late.py
@@ -2,9 +2,15 @@
2
2
  requires = ["setuptools>=77"]
3
3
  build-backend = "setuptools.build_meta"
4
4
 
5
+ [project.urls]
6
+ Homepage = "https://sitegoose.com"
7
+ Source = "https://github.com/StevieC87/Sitegoose-Packages/tree/main/platform-python"
8
+ Issues = "https://github.com/StevieC87/Sitegoose-Packages/issues"
9
+ Security = "https://sitegoose.com/security"
10
+
5
11
  [project]
6
12
  name = "analytics-platform"
7
- version = "0.3.7"
13
+ version = "0.3.9"
8
14
  description = "Firewall, error monitoring and server analytics for your Python server, run from your analytics dashboard."
9
15
  readme = "README.md"
10
16
  requires-python = ">=3.8"
@@ -0,0 +1,28 @@
1
+ """The security headers taken from the platform config, against the cases every
2
+ client shares: sdk/protocols/header-cases.json. Only the nine the dashboard can
3
+ produce get through, so all the packages add the same ones."""
4
+ import json
5
+ import os
6
+ import unittest
7
+
8
+ from analytics_platform.core import safe_header
9
+
10
+ CASES = os.path.join(os.path.dirname(__file__), "..", "..", "protocols", "header-cases.json")
11
+
12
+
13
+ class HeaderCases(unittest.TestCase):
14
+ def test_shared_cases(self):
15
+ with open(CASES, encoding="utf-8") as f:
16
+ cases = json.load(f)["cases"]
17
+ for case in cases:
18
+ value = case["value"] * case.get("repeat", 1)
19
+ with self.subTest(name=case["name"], value=case["value"][:40], repeat=case.get("repeat")):
20
+ self.assertEqual(safe_header([case["name"], value]), case["ok"])
21
+
22
+ def test_only_pairs_of_strings(self):
23
+ for pair in (None, "X-Frame-Options", ["X-Frame-Options"], ["X-Frame-Options", 1], ["X-Frame-Options", "DENY", "x"]):
24
+ self.assertFalse(safe_header(pair))
25
+
26
+
27
+ if __name__ == "__main__":
28
+ unittest.main()
@@ -0,0 +1,27 @@
1
+ """The type a collector answer is served with from the site's own domain,
2
+ against the cases every client shares: sdk/protocols/proxy-answer-cases.json."""
3
+ import json
4
+ import os
5
+ import unittest
6
+
7
+ from analytics_platform.proxy import Proxy, answer_type
8
+
9
+ CASES = os.path.join(os.path.dirname(__file__), "..", "..", "protocols", "proxy-answer-cases.json")
10
+
11
+
12
+ class ProxyAnswerCases(unittest.TestCase):
13
+ def test_shared_cases(self):
14
+ with open(CASES, encoding="utf-8") as f:
15
+ cases = json.load(f)["cases"]
16
+ for case in cases:
17
+ with self.subTest(type=case["type"]):
18
+ self.assertEqual(answer_type(case["type"]), case["served"])
19
+
20
+ def test_nosniff_and_only_cache_and_retry(self):
21
+ upstream = {"Content-Type": "text/html", "Cache-Control": "no-store", "Set-Cookie": "a=b", "Refresh": "0;url=x"}
22
+ self.assertEqual(Proxy._answer_headers(upstream), [
23
+ ("Content-Type", "text/plain; charset=utf-8"), ("X-Content-Type-Options", "nosniff"), ("Cache-Control", "no-store")])
24
+
25
+
26
+ if __name__ == "__main__":
27
+ unittest.main()
@@ -0,0 +1,156 @@
1
+ """The one-line setup: keys from ANALYTICS_* or a .env file (never written to
2
+ os.environ), and analytics_platform.start putting itself in front of Flask
3
+ and FastAPI/Starlette apps on their first request, and Django's settings
4
+ block becoming optional. The framework tests skip where the framework isn't
5
+ installed; run them where Flask, FastAPI (with httpx) and Django are."""
6
+ import os
7
+ import sys
8
+ import tempfile
9
+ import unittest
10
+ from unittest import mock
11
+
12
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
13
+
14
+ from analytics_platform import Platform, PlatformMiddleware, _env # noqa: E402
15
+
16
+ SITE = "a" * 24
17
+ KEYS = {"ANALYTICS_SITE_ID": SITE, "ANALYTICS_SITE_SECRET": "s3cret", "ANALYTICS_ENDPOINT": "http://127.0.0.1:9"}
18
+ OFF = {name: False for name in ("firewall", "errors", "analytics", "performance", "crawlers", "forms", "headers", "audit",
19
+ "flags", "maintenance", "inventory", "posture", "resources", "proxy")}
20
+
21
+
22
+ def installed(name):
23
+ try:
24
+ __import__(name)
25
+ return True
26
+ except Exception:
27
+ return False
28
+
29
+
30
+ class EnvTest(unittest.TestCase):
31
+ def test_options_from_the_environment(self):
32
+ found = _env.options_from({**KEYS, "ANALYTICS_TRUST_PROXY": "2", "ANALYTICS_SERVER_NAME": "api"})
33
+ self.assertEqual(found, {"site_id": SITE, "secret": "s3cret", "endpoint": "http://127.0.0.1:9",
34
+ "server_name": "api", "trust_proxy": 2})
35
+ self.assertEqual(_env.options_from({"ANALYTICS_TRUST_PROXY": "Cloudflare"})["trust_proxy"], "cloudflare")
36
+ self.assertEqual(_env.options_from({"ANALYTICS_FIREWALL_ENDPOINT": "https://x"})["endpoint"], "https://x")
37
+ self.assertEqual(_env.options_from({"ANALYTICS_TRUST_PROXY": "nonsense", "ANALYTICS_SITE_ID": " "}), {})
38
+
39
+ def test_dotenv_lines_are_read_and_never_loaded(self):
40
+ with tempfile.TemporaryDirectory() as folder:
41
+ path = os.path.join(folder, ".env")
42
+ with open(path, "w") as handle:
43
+ handle.write('DATABASE_URL=x\nANALYTICS_SITE_ID="%s"\nexport ANALYTICS_SITE_SECRET=from-file # note\n' % SITE)
44
+ with mock.patch.dict(os.environ, {"ANALYTICS_ENDPOINT": "http://127.0.0.1:9"}, clear=True):
45
+ filled = _env.from_environment({"secret": None}, path)
46
+ self.assertNotIn("ANALYTICS_SITE_SECRET", os.environ)
47
+ self.assertNotIn("DATABASE_URL", os.environ)
48
+ self.assertEqual((filled["site_id"], filled["secret"], filled["endpoint"]), (SITE, "from-file", "http://127.0.0.1:9"))
49
+
50
+ def test_the_environment_wins_over_the_file_and_given_options_over_both(self):
51
+ with tempfile.TemporaryDirectory() as folder:
52
+ path = os.path.join(folder, ".env")
53
+ with open(path, "w") as handle:
54
+ handle.write("ANALYTICS_SITE_SECRET=file\nANALYTICS_SERVER_NAME=file\n")
55
+ with mock.patch.dict(os.environ, {"ANALYTICS_SITE_SECRET": "env"}, clear=True):
56
+ filled = _env.from_environment({"server_name": "given"}, path)
57
+ self.assertEqual((filled["secret"], filled["server_name"]), ("env", "given"))
58
+
59
+ def test_platform_with_no_keys_reads_them_from_the_environment(self):
60
+ with mock.patch.dict(os.environ, KEYS, clear=True):
61
+ platform = Platform(**OFF)
62
+ self.assertTrue(platform.core.valid)
63
+ with mock.patch.dict(os.environ, {}, clear=True), mock.patch("builtins.print") as printed:
64
+ self.assertFalse(Platform(**OFF).core.valid)
65
+ self.assertIn("ANALYTICS_SITE_SECRET", printed.call_args[0][0])
66
+
67
+
68
+ def started():
69
+ """analytics_platform.start, with a fresh platform made from KEYS."""
70
+ from analytics_platform import start
71
+ start._made = None
72
+ return start
73
+
74
+
75
+ @unittest.skipUnless(installed("flask"), "Flask isn't installed")
76
+ class FlaskTest(unittest.TestCase):
77
+ def test_hooks_itself_in_on_the_first_request_wherever_the_import_is(self):
78
+ from flask import Flask
79
+ app = Flask("before") # made before the import
80
+ app.get("/")(lambda: "ok")
81
+ with mock.patch.dict(os.environ, KEYS):
82
+ start = started()
83
+ response = app.test_client().get("/")
84
+ self.assertEqual(response.status_code, 200)
85
+ self.assertIs(app.extensions["analytics_platform"], start._made)
86
+ wrapped = app.wsgi_app
87
+ app.test_client().get("/")
88
+ self.assertIs(app.wsgi_app, wrapped, "wrapped once, not on every request")
89
+
90
+ def test_an_app_set_up_by_hand_is_left_alone(self):
91
+ from flask import Flask
92
+ with mock.patch.dict(os.environ, KEYS):
93
+ started()
94
+ own = Platform(**OFF)
95
+ app = Flask("by_hand")
96
+ app.get("/")(lambda: "ok")
97
+ own.init_flask(app)
98
+ wrapped = app.wsgi_app
99
+ self.assertEqual(app.test_client().get("/").status_code, 200)
100
+ self.assertIs(app.extensions["analytics_platform"], own)
101
+ self.assertIs(app.wsgi_app, wrapped)
102
+
103
+
104
+ @unittest.skipUnless(installed("fastapi") and installed("httpx"), "FastAPI or httpx isn't installed")
105
+ class FastAPITest(unittest.TestCase):
106
+ def test_outermost_of_your_middleware_and_not_in_a_mounted_app(self):
107
+ from fastapi import FastAPI
108
+ from fastapi.testclient import TestClient
109
+ from starlette.middleware.gzip import GZipMiddleware
110
+ with mock.patch.dict(os.environ, KEYS):
111
+ started()
112
+ app = FastAPI()
113
+ app.add_middleware(GZipMiddleware)
114
+ sub = FastAPI()
115
+ sub.get("/x")(lambda: {"ok": True})
116
+ app.mount("/sub", sub)
117
+ app.get("/")(lambda: {"ok": True})
118
+ client = TestClient(app)
119
+ self.assertEqual(client.get("/").status_code, 200)
120
+ self.assertEqual(client.get("/sub/x").status_code, 200)
121
+ self.assertEqual([m.cls for m in app.user_middleware], [PlatformMiddleware, GZipMiddleware])
122
+ self.assertEqual([m.cls for m in sub.user_middleware], [], "a mounted app isn't counted twice")
123
+
124
+ def test_an_app_with_it_by_hand_gets_no_second_one(self):
125
+ from fastapi import FastAPI
126
+ from fastapi.testclient import TestClient
127
+ with mock.patch.dict(os.environ, KEYS):
128
+ started()
129
+ app = FastAPI()
130
+ app.add_middleware(PlatformMiddleware, platform=Platform(**OFF))
131
+ app.get("/")(lambda: {"ok": True})
132
+ self.assertEqual(TestClient(app).get("/").status_code, 200)
133
+ self.assertEqual([m.cls for m in app.user_middleware], [PlatformMiddleware])
134
+
135
+
136
+ @unittest.skipUnless(installed("django"), "Django isn't installed")
137
+ class DjangoTest(unittest.TestCase):
138
+ def test_no_settings_block_the_keys_from_the_projects_dotenv(self):
139
+ from django.conf import settings
140
+ with tempfile.TemporaryDirectory() as folder:
141
+ with open(os.path.join(folder, ".env"), "w") as handle:
142
+ handle.write("\n".join("%s=%s" % item for item in KEYS.items()))
143
+ if not settings.configured:
144
+ settings.configure(BASE_DIR=folder)
145
+ else:
146
+ settings.BASE_DIR = folder
147
+ from analytics_platform import django as integration
148
+ integration._platform = None
149
+ with mock.patch.dict(os.environ, {}, clear=True):
150
+ platform = integration.get_platform()
151
+ self.assertNotIn("ANALYTICS_SITE_SECRET", os.environ)
152
+ self.assertTrue(platform.core.valid)
153
+
154
+
155
+ if __name__ == "__main__":
156
+ unittest.main()