runlight 0.3.2__tar.gz → 0.4.0__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 (106) hide show
  1. {runlight-0.3.2 → runlight-0.4.0}/PKG-INFO +1 -1
  2. {runlight-0.3.2 → runlight-0.4.0}/pyproject.toml +1 -1
  3. runlight-0.4.0/src/runlight/agentcheck.py +227 -0
  4. runlight-0.4.0/src/runlight/assets/agent-ranges.json +849 -0
  5. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/assets/build.json +3 -3
  6. runlight-0.4.0/src/runlight/assets/dashboard.css +1 -0
  7. runlight-0.4.0/src/runlight/assets/dashboard.js +131 -0
  8. runlight-0.4.0/src/runlight/assets/locales.json +7 -0
  9. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/assets.py +9 -1
  10. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/core.py +53 -3
  11. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/data/agents.py +21 -15
  12. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/routes.py +7 -2
  13. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/server/agents.py +15 -7
  14. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/server/cli.py +16 -0
  15. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/server/standalone.py +3 -1
  16. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/store/sql_store.py +13 -4
  17. runlight-0.3.2/src/runlight/assets/dashboard.css +0 -1
  18. runlight-0.3.2/src/runlight/assets/dashboard.js +0 -131
  19. runlight-0.3.2/src/runlight/assets/locales.json +0 -7
  20. {runlight-0.3.2 → runlight-0.4.0}/.gitignore +0 -0
  21. {runlight-0.3.2 → runlight-0.4.0}/LICENSE +0 -0
  22. {runlight-0.3.2 → runlight-0.4.0}/README.md +0 -0
  23. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/__init__.py +0 -0
  24. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/_aesgcm.py +0 -0
  25. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/_intl.py +0 -0
  26. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/_js.py +0 -0
  27. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/accounts/__init__.py +0 -0
  28. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/accounts/auth.py +0 -0
  29. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/accounts/crypto.py +0 -0
  30. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/accounts/pages.py +0 -0
  31. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/accounts/web.py +0 -0
  32. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/asgi.py +0 -0
  33. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/assets/assistant-action.json +0 -0
  34. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/assets/pick-return.js +0 -0
  35. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/assets/picker.js +0 -0
  36. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/assets/tracker.js +0 -0
  37. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/assets/world.json +0 -0
  38. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/assistant.py +0 -0
  39. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/body.py +0 -0
  40. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/brand.py +0 -0
  41. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/connect.py +0 -0
  42. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/data/__init__.py +0 -0
  43. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/data/intl.json +0 -0
  44. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/data/sources.py +0 -0
  45. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/db.py +0 -0
  46. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/django/__init__.py +0 -0
  47. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/django/apps.py +0 -0
  48. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/django/management/__init__.py +0 -0
  49. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/django/management/commands/__init__.py +0 -0
  50. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/django/management/commands/runlight_check.py +0 -0
  51. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/env.py +0 -0
  52. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/fastapi.py +0 -0
  53. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/flask.py +0 -0
  54. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/funnels.py +0 -0
  55. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/geo.py +0 -0
  56. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/goals.py +0 -0
  57. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/hash.py +0 -0
  58. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/http/__init__.py +0 -0
  59. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/http/fetcher.py +0 -0
  60. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/http/headers.py +0 -0
  61. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/http/message.py +0 -0
  62. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/http/search_params.py +0 -0
  63. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/http/url.py +0 -0
  64. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/icon.py +0 -0
  65. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/importers/__init__.py +0 -0
  66. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/importers/bitly.py +0 -0
  67. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/importers/csvvisits.py +0 -0
  68. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/importers/dub.py +0 -0
  69. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/importers/http.py +0 -0
  70. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/importers/index.py +0 -0
  71. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/importers/rebrandly.py +0 -0
  72. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/importers/shortio.py +0 -0
  73. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/importers/types.py +0 -0
  74. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/importers/umami.py +0 -0
  75. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/importers/visits.py +0 -0
  76. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/importers/write.py +0 -0
  77. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/journeys.py +0 -0
  78. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/limit.py +0 -0
  79. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/links.py +0 -0
  80. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/mail/__init__.py +0 -0
  81. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/mail/secret.py +0 -0
  82. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/mail/ses.py +0 -0
  83. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/mail/smtp.py +0 -0
  84. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/mail/transports.py +0 -0
  85. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/mcp.py +0 -0
  86. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/messages.py +0 -0
  87. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/mmdb.py +0 -0
  88. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/oauth.py +0 -0
  89. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/payload.py +0 -0
  90. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/py.typed +0 -0
  91. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/query.py +0 -0
  92. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/reports.py +0 -0
  93. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/safefetch.py +0 -0
  94. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/serve.py +0 -0
  95. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/server/__init__.py +0 -0
  96. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/server/dbip.py +0 -0
  97. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/server/secret.py +0 -0
  98. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/sources.py +0 -0
  99. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/store/__init__.py +0 -0
  100. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/store/sql.py +0 -0
  101. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/store/stores.py +0 -0
  102. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/time.py +0 -0
  103. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/ua.py +0 -0
  104. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/version.py +0 -0
  105. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/wsgi.py +0 -0
  106. {runlight-0.3.2 → runlight-0.4.0}/src/runlight/zip.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: runlight
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: Privacy friendly web analytics that lives inside your Python app. Mount a route, add a script tag, read your stats at /runlight.
5
5
  Project-URL: Homepage, https://runlight.sh
6
6
  Project-URL: Documentation, https://runlight.sh/docs/python/
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "runlight"
7
- version = "0.3.2"
7
+ version = "0.4.0"
8
8
  description = "Privacy friendly web analytics that lives inside your Python app. Mount a route, add a script tag, read your stats at /runlight."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -0,0 +1,227 @@
1
+ """Checks a request that names an AI agent against the addresses that agent's company publishes. Every list is in
2
+ the same shape, {"prefixes": [{"ipv4Prefix": "a.b.c.d/n"} or {"ipv6Prefix": "x::/n"}]}. A port of the TypeScript
3
+ SDK's agentcheck.ts.
4
+
5
+ The answer is "verified" when the address is in the list, "mismatch" when the company publishes a list and the
6
+ address is not in it, and "" when there is nothing to check against: no list for that agent, or no usable address.
7
+ The address is only compared, never kept.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from collections.abc import Mapping
14
+ from typing import Any
15
+
16
+ from . import _js
17
+ from .http import Fetcher
18
+
19
+ # A list of more prefixes than this, or a reply bigger than MAX_LIST_BYTES, is not a list we read.
20
+ MAX_PREFIXES = 20_000
21
+ MAX_LIST_BYTES = 4_000_000
22
+
23
+ _DIGITS = re.compile(r"[0-9]{1,3}\Z")
24
+ _HEX_TEXT = re.compile(r"[0-9a-fA-F:.]+\Z")
25
+ _GROUP = re.compile(r"[0-9a-fA-F]{1,4}\Z")
26
+
27
+ # An address or a prefix: (family, number), family 4 or 6.
28
+ Address = tuple[int, int]
29
+
30
+
31
+ def _ipv4(text: str) -> int | None:
32
+ parts = text.split(".")
33
+ if len(parts) != 4:
34
+ return None
35
+ n = 0
36
+ for part in parts:
37
+ if not _DIGITS.match(part):
38
+ return None
39
+ byte = int(part)
40
+ if byte > 255:
41
+ return None
42
+ n = n * 256 + byte
43
+ return n
44
+
45
+
46
+ def _ipv6(text: str) -> int | None:
47
+ if not _HEX_TEXT.match(text) or len(text.split("::")) > 2:
48
+ return None
49
+ # A dotted IPv4 tail ("::ffff:1.2.3.4") stands for the last two groups.
50
+ groups = text
51
+ dot = text.rfind(":")
52
+ if "." in text:
53
+ tail = _ipv4(text[dot + 1 :])
54
+ if tail is None:
55
+ return None
56
+ groups = f"{text[: dot + 1]}{tail >> 16:x}:{tail & 0xFFFF:x}"
57
+ pieces = groups.split("::")
58
+ head = pieces[0]
59
+ rest = pieces[1] if len(pieces) > 1 else None
60
+ left = head.split(":") if head else []
61
+ right = [] if rest is None else rest.split(":") if rest else []
62
+ missing = 8 - len(left) - len(right)
63
+ if (missing != 0) if rest is None else (missing < 1):
64
+ return None
65
+ every = [*left, *(["0"] * (0 if rest is None else missing)), *right]
66
+ n = 0
67
+ for group in every:
68
+ if not _GROUP.match(group):
69
+ return None
70
+ n = (n << 16) | int(group, 16)
71
+ return n
72
+
73
+
74
+ def parse_address(text: str) -> Address | None:
75
+ """An address as the check reads it: surrounding space, [brackets], and an IPv6 zone ("%eth0") are ignored, and
76
+ an IPv4 address written as IPv6 (::ffff:1.2.3.4) is read as IPv4. (4, n) or (6, n), or None when it is not one."""
77
+ s = _js.trim(text)
78
+ if s.startswith("[") and s.endswith("]"):
79
+ s = s[1:-1]
80
+ zone = s.find("%")
81
+ if zone != -1:
82
+ s = s[:zone]
83
+ if not s:
84
+ return None
85
+ if ":" not in s:
86
+ n = _ipv4(s)
87
+ return None if n is None else (4, n)
88
+ n = _ipv6(s)
89
+ if n is None:
90
+ return None
91
+ if n >> 32 == 0xFFFF:
92
+ return (4, n & 0xFFFFFFFF)
93
+ return (6, n)
94
+
95
+
96
+ def _parse_prefix(text: str) -> tuple[int, int, int] | None:
97
+ """A prefix such as 20.171.206.0/24 or 2600:1f28::/56 as (family, base, bits), or None. Bits past the length are
98
+ ignored."""
99
+ slash = text.find("/")
100
+ if slash == -1:
101
+ return None
102
+ bits_text = text[slash + 1 :]
103
+ if not _DIGITS.match(bits_text):
104
+ return None
105
+ bits = int(bits_text)
106
+ head = text[:slash]
107
+ if ":" in head:
108
+ n = _ipv6(head)
109
+ if n is None or bits > 128:
110
+ return None
111
+ return (6, n, bits)
112
+ n = _ipv4(head)
113
+ if n is None or bits > 32:
114
+ return None
115
+ return (4, n, bits)
116
+
117
+
118
+ def _covers(prefix: tuple[int, int, int], address: Address) -> bool:
119
+ family, base, bits = prefix
120
+ if family != address[0]:
121
+ return False
122
+ shift = (32 if family == 4 else 128) - bits
123
+ return address[1] >> shift == base >> shift
124
+
125
+
126
+ def read_range_list(body: Any) -> list[str] | None:
127
+ """The prefixes in a published list, or None when the reply is not a list in the expected shape (not an object,
128
+ no prefixes, an entry that is not a prefix, or too many). One bad entry rejects the whole list, so a half broken
129
+ download never replaces a good copy."""
130
+ if not isinstance(body, dict):
131
+ return None
132
+ prefixes = body.get("prefixes")
133
+ if not isinstance(prefixes, list) or len(prefixes) == 0 or len(prefixes) > MAX_PREFIXES:
134
+ return None
135
+ out: list[str] = []
136
+ for entry in prefixes:
137
+ if not isinstance(entry, dict):
138
+ return None
139
+ v4 = entry.get("ipv4Prefix")
140
+ v6 = entry.get("ipv6Prefix")
141
+ text = v4 if isinstance(v4, str) else v6 if isinstance(v6, str) else None
142
+ if text is None:
143
+ return None
144
+ prefix = _parse_prefix(_js.trim(text))
145
+ if prefix is None or isinstance(v4, str) != (prefix[0] == 4):
146
+ return None
147
+ out.append(_js.trim(text))
148
+ return out
149
+
150
+
151
+ def download_range_list(fetcher: Fetcher, url: str, timeout_ms: int = 15_000) -> list[str] | None:
152
+ """Downloads one published list. None when it cannot be fetched or read; the caller keeps what it had."""
153
+ try:
154
+ response = fetcher.fetch(
155
+ url,
156
+ {
157
+ "headers": {"accept": "application/json", "user-agent": "Runlight (+https://runlight.sh)"},
158
+ "redirect": "follow",
159
+ "timeoutMs": timeout_ms,
160
+ "maxBytes": MAX_LIST_BYTES,
161
+ },
162
+ )
163
+ if not response.ok:
164
+ return None
165
+ length = _js.number(response.headers.get("content-length") or 0)
166
+ if length > MAX_LIST_BYTES:
167
+ return None
168
+ content = response.content()
169
+ if len(content) > MAX_LIST_BYTES:
170
+ return None
171
+ return read_range_list(_js.loads(content))
172
+ except Exception:
173
+ return None
174
+
175
+
176
+ _compiled: dict[int, tuple[list[str], list[tuple[int, int, int]]]] = {}
177
+
178
+
179
+ def _prefixes_of(ranges: list[str]) -> list[tuple[int, int, int]]:
180
+ # Kept with the list itself, so a list replaced by a download is parsed again and the old one is let go.
181
+ cached = _compiled.get(id(ranges))
182
+ if cached is not None and cached[0] is ranges:
183
+ return cached[1]
184
+ parsed = [p for p in (_parse_prefix(text) for text in ranges) if p is not None]
185
+ if len(_compiled) > 64:
186
+ _compiled.clear()
187
+ _compiled[id(ranges)] = (ranges, parsed)
188
+ return parsed
189
+
190
+
191
+ def in_ranges(ip: str, ranges: list[str]) -> bool:
192
+ """Whether `ip` is inside any of the prefixes. False for an address that cannot be read."""
193
+ address = parse_address(ip)
194
+ return address is not None and any(_covers(p, address) for p in _prefixes_of(ranges))
195
+
196
+
197
+ def check_agent(agent: Mapping[str, Any], ip: str, lists: Mapping[str, list[str]]) -> str:
198
+ """The check for one request that names `agent`, made from `ip` (see the top of this module): "verified",
199
+ "mismatch", or ""."""
200
+ url = agent.get("ranges")
201
+ found = lists.get(url) if url else None
202
+ if not found or parse_address(ip) is None:
203
+ return ""
204
+ return "verified" if in_ranges(ip, found) else "mismatch"
205
+
206
+
207
+ def saved_lists(stored: str | None) -> dict[str, Any] | None:
208
+ """The lists saved in the "agent-ranges" setting, {"at", "lists"}, each list read as a download is. None when
209
+ nothing usable is saved."""
210
+ if not stored:
211
+ return None
212
+ try:
213
+ parsed = _js.loads(stored)
214
+ except Exception:
215
+ return None
216
+ if parsed is None:
217
+ return None
218
+ lists: dict[str, list[str]] = {}
219
+ raw = parsed.get("lists") if isinstance(parsed, dict) else None
220
+ if isinstance(raw, dict):
221
+ for url, ranges in raw.items():
222
+ entries = [{"ipv6Prefix": p} if ":" in _js.string(p) else {"ipv4Prefix": p} for p in ranges] if isinstance(ranges, list) else None
223
+ prefixes = read_range_list({"prefixes": entries})
224
+ if prefixes:
225
+ lists[url] = prefixes
226
+ at = _js.number(parsed.get("at")) if isinstance(parsed, dict) else 0
227
+ return {"at": at if at == at else 0, "lists": lists}