runlight 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. runlight/__init__.py +55 -0
  2. runlight/_aesgcm.py +202 -0
  3. runlight/_intl.py +202 -0
  4. runlight/_js.py +567 -0
  5. runlight/accounts/__init__.py +35 -0
  6. runlight/accounts/auth.py +738 -0
  7. runlight/accounts/crypto.py +152 -0
  8. runlight/accounts/pages.py +264 -0
  9. runlight/accounts/web.py +654 -0
  10. runlight/asgi.py +185 -0
  11. runlight/assets/build.json +10 -0
  12. runlight/assets/dashboard.css +1 -0
  13. runlight/assets/dashboard.js +81 -0
  14. runlight/assets/locales.json +7 -0
  15. runlight/assets/picker.js +1 -0
  16. runlight/assets/tracker.js +1 -0
  17. runlight/assets/world.json +1 -0
  18. runlight/assets.py +63 -0
  19. runlight/assistant.py +447 -0
  20. runlight/body.py +41 -0
  21. runlight/brand.py +14 -0
  22. runlight/connect.py +204 -0
  23. runlight/core.py +1469 -0
  24. runlight/data/__init__.py +1 -0
  25. runlight/data/agents.py +45 -0
  26. runlight/data/intl.json +1 -0
  27. runlight/data/sources.py +88 -0
  28. runlight/db.py +691 -0
  29. runlight/django/__init__.py +168 -0
  30. runlight/django/apps.py +11 -0
  31. runlight/django/management/__init__.py +0 -0
  32. runlight/django/management/commands/__init__.py +0 -0
  33. runlight/django/management/commands/runlight_check.py +15 -0
  34. runlight/env.py +16 -0
  35. runlight/fastapi.py +30 -0
  36. runlight/flask.py +48 -0
  37. runlight/funnels.py +56 -0
  38. runlight/geo.py +141 -0
  39. runlight/goals.py +141 -0
  40. runlight/hash.py +34 -0
  41. runlight/http/__init__.py +20 -0
  42. runlight/http/fetcher.py +200 -0
  43. runlight/http/headers.py +82 -0
  44. runlight/http/message.py +127 -0
  45. runlight/http/search_params.py +102 -0
  46. runlight/http/url.py +360 -0
  47. runlight/icon.py +177 -0
  48. runlight/importers/__init__.py +7 -0
  49. runlight/importers/bitly.py +99 -0
  50. runlight/importers/csvvisits.py +157 -0
  51. runlight/importers/dub.py +130 -0
  52. runlight/importers/http.py +282 -0
  53. runlight/importers/index.py +65 -0
  54. runlight/importers/rebrandly.py +51 -0
  55. runlight/importers/shortio.py +103 -0
  56. runlight/importers/types.py +49 -0
  57. runlight/importers/umami.py +146 -0
  58. runlight/importers/visits.py +400 -0
  59. runlight/importers/write.py +221 -0
  60. runlight/journeys.py +138 -0
  61. runlight/limit.py +46 -0
  62. runlight/links.py +159 -0
  63. runlight/mail/__init__.py +21 -0
  64. runlight/mail/secret.py +60 -0
  65. runlight/mail/ses.py +116 -0
  66. runlight/mail/smtp.py +299 -0
  67. runlight/mail/transports.py +268 -0
  68. runlight/mcp.py +342 -0
  69. runlight/messages.py +119 -0
  70. runlight/mmdb.py +208 -0
  71. runlight/oauth.py +463 -0
  72. runlight/payload.py +133 -0
  73. runlight/py.typed +0 -0
  74. runlight/query.py +81 -0
  75. runlight/reports.py +269 -0
  76. runlight/routes.py +2512 -0
  77. runlight/safefetch.py +264 -0
  78. runlight/serve.py +112 -0
  79. runlight/server/__init__.py +2 -0
  80. runlight/server/agents.py +504 -0
  81. runlight/server/cli.py +366 -0
  82. runlight/server/dbip.py +192 -0
  83. runlight/server/standalone.py +211 -0
  84. runlight/sources.py +173 -0
  85. runlight/store/__init__.py +7 -0
  86. runlight/store/sql.py +411 -0
  87. runlight/store/sql_store.py +1638 -0
  88. runlight/store/stores.py +73 -0
  89. runlight/time.py +360 -0
  90. runlight/ua.py +131 -0
  91. runlight/version.py +17 -0
  92. runlight/wsgi.py +190 -0
  93. runlight/zip.py +75 -0
  94. runlight-0.1.0.dist-info/METADATA +82 -0
  95. runlight-0.1.0.dist-info/RECORD +98 -0
  96. runlight-0.1.0.dist-info/WHEEL +4 -0
  97. runlight-0.1.0.dist-info/entry_points.txt +2 -0
  98. runlight-0.1.0.dist-info/licenses/LICENSE +21 -0
runlight/__init__.py ADDED
@@ -0,0 +1,55 @@
1
+ """Runlight: privacy friendly web analytics that lives inside your Python app.
2
+
3
+ from runlight import Runlight
4
+ from runlight.store import Stores
5
+
6
+ rl = Runlight(store=Stores.sqlite("./data/runlight.db"), site={"name": "example.com", "hostnames": ["example.com"]})
7
+ routes = rl.routes() # routes.handle(request) answers /runlight/*
8
+ # or mount it: runlight.wsgi, runlight.asgi, runlight.django, runlight.flask, runlight.fastapi
9
+
10
+ Then add <script defer src="/runlight/s.js"></script> to your pages.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+ from .http import Headers, Request, Response, Url
18
+
19
+ __all__ = [
20
+ "API_VERSION",
21
+ "LINK_DOMAIN_CHECK",
22
+ "SESSION_IDLE_MS",
23
+ "VERSION",
24
+ "Headers",
25
+ "Request",
26
+ "Response",
27
+ "Runlight",
28
+ "SettingsError",
29
+ "Stores",
30
+ "Url",
31
+ "runlight",
32
+ ]
33
+
34
+
35
+ def runlight(options: Any = None, **kwargs: Any) -> Any:
36
+ """A Runlight, as runlight() in the TypeScript SDK makes one."""
37
+ from .core import Runlight
38
+
39
+ return Runlight(options, **kwargs)
40
+
41
+
42
+ def __getattr__(name: str) -> Any:
43
+ if name in ("Runlight", "SettingsError", "SESSION_IDLE_MS", "LINK_DOMAIN_CHECK"):
44
+ from . import core
45
+
46
+ return getattr(core, name)
47
+ if name == "Stores":
48
+ from .store import Stores
49
+
50
+ return Stores
51
+ if name in ("VERSION", "API_VERSION"):
52
+ from . import version
53
+
54
+ return getattr(version, name)
55
+ raise AttributeError(name)
runlight/_aesgcm.py ADDED
@@ -0,0 +1,202 @@
1
+ """AES-GCM, as Web Crypto's AES-GCM seals and opens: the ciphertext with its 16 byte tag at the end.
2
+
3
+ The standard library has no AES, so this uses the `cryptography` package's AESGCM when it is installed, and
4
+ otherwise a plain Python AES and GHASH. Runlight seals short things (two-factor secrets, a mail service's keys,
5
+ an assistant's key), so the plain one is quick enough, and both give the same bytes.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ try: # pragma: no cover - depends on what is installed
11
+ from cryptography.exceptions import InvalidTag as _InvalidTag
12
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM as _AESGCM
13
+ except ImportError: # pragma: no cover
14
+ _AESGCM = None
15
+ _InvalidTag = None
16
+
17
+
18
+ class InvalidTag(ValueError):
19
+ """The ciphertext, IV, or key is not the one sealed: Web Crypto rejects with an OperationError then."""
20
+
21
+
22
+ def encrypt(key: bytes, iv: bytes, data: bytes, aad: bytes = b"") -> bytes:
23
+ """The ciphertext followed by its 16 byte tag."""
24
+ _check(key, iv)
25
+ if _AESGCM is not None:
26
+ return _AESGCM(key).encrypt(iv, data, aad or None)
27
+ return _Gcm(key).seal(iv, data, aad)
28
+
29
+
30
+ def decrypt(key: bytes, iv: bytes, data: bytes, aad: bytes = b"") -> bytes:
31
+ """The plaintext. Raises InvalidTag when the tag does not check out."""
32
+ _check(key, iv)
33
+ if len(data) < 16:
34
+ raise InvalidTag("The data is shorter than its tag")
35
+ if _AESGCM is not None:
36
+ try:
37
+ return _AESGCM(key).decrypt(iv, data, aad or None)
38
+ except _InvalidTag: # type: ignore[misc]
39
+ raise InvalidTag("The tag does not match") from None
40
+ return _Gcm(key).open(iv, data, aad)
41
+
42
+
43
+ def _check(key: bytes, iv: bytes) -> None:
44
+ if len(key) not in (16, 24, 32):
45
+ raise ValueError("An AES key is 16, 24, or 32 bytes")
46
+ # Node's Web Crypto refuses an IV under 12 bytes.
47
+ if len(iv) < 12:
48
+ raise ValueError("An AES-GCM IV is at least 12 bytes")
49
+
50
+
51
+ # AES (FIPS 197), with the round function in four tables of 32-bit words.
52
+
53
+ _SBOX: list[int] = []
54
+ _T0: list[int] = []
55
+ _T1: list[int] = []
56
+ _T2: list[int] = []
57
+ _T3: list[int] = []
58
+
59
+
60
+ def _tables() -> None:
61
+ def xtime(a: int) -> int:
62
+ a <<= 1
63
+ return a ^ 0x11B if a & 0x100 else a
64
+
65
+ # Powers and logs of 3 in GF(2^8), for the inverses.
66
+ exp = [0] * 256
67
+ log = [0] * 256
68
+ x = 1
69
+ for i in range(255):
70
+ exp[i] = x
71
+ log[x] = i
72
+ x ^= xtime(x)
73
+ for a in range(256):
74
+ inv = 0 if a == 0 else exp[(255 - log[a]) % 255]
75
+ s = inv
76
+ for shift in range(1, 5):
77
+ s ^= ((inv << shift) | (inv >> (8 - shift))) & 0xFF
78
+ _SBOX.append(s ^ 0x63)
79
+ for s in _SBOX:
80
+ s2 = xtime(s)
81
+ s3 = s2 ^ s
82
+ word = (s2 << 24) | (s << 16) | (s << 8) | s3
83
+ _T0.append(word)
84
+ _T1.append(((word >> 8) | (word << 24)) & 0xFFFFFFFF)
85
+ _T2.append(((word >> 16) | (word << 16)) & 0xFFFFFFFF)
86
+ _T3.append(((word >> 24) | (word << 8)) & 0xFFFFFFFF)
87
+
88
+
89
+ _tables()
90
+
91
+
92
+ def _expand(key: bytes) -> list[int]:
93
+ nk = len(key) // 4
94
+ rounds = nk + 6
95
+ words = [int.from_bytes(key[i * 4 : i * 4 + 4], "big") for i in range(nk)]
96
+ rcon = 1
97
+ sbox = _SBOX
98
+ for i in range(nk, 4 * (rounds + 1)):
99
+ t = words[i - 1]
100
+ if i % nk == 0:
101
+ t = ((t << 8) | (t >> 24)) & 0xFFFFFFFF
102
+ t = (sbox[t >> 24] << 24) | (sbox[(t >> 16) & 255] << 16) | (sbox[(t >> 8) & 255] << 8) | sbox[t & 255]
103
+ t ^= rcon << 24
104
+ rcon <<= 1
105
+ if rcon & 0x100:
106
+ rcon ^= 0x11B
107
+ elif nk > 6 and i % nk == 4:
108
+ t = (sbox[t >> 24] << 24) | (sbox[(t >> 16) & 255] << 16) | (sbox[(t >> 8) & 255] << 8) | sbox[t & 255]
109
+ words.append(words[i - nk] ^ t)
110
+ return words
111
+
112
+
113
+ def _encrypt_block(w: list[int], block: int) -> int:
114
+ """One 128-bit block, as an integer, through AES with the expanded key `w`."""
115
+ t0, t1, t2, t3, sbox = _T0, _T1, _T2, _T3, _SBOX
116
+ a = (block >> 96) ^ w[0]
117
+ b = ((block >> 64) & 0xFFFFFFFF) ^ w[1]
118
+ c = ((block >> 32) & 0xFFFFFFFF) ^ w[2]
119
+ d = (block & 0xFFFFFFFF) ^ w[3]
120
+ rounds = len(w) // 4 - 1
121
+ k = 4
122
+ for _ in range(rounds - 1):
123
+ a, b, c, d = (
124
+ t0[a >> 24] ^ t1[(b >> 16) & 255] ^ t2[(c >> 8) & 255] ^ t3[d & 255] ^ w[k],
125
+ t0[b >> 24] ^ t1[(c >> 16) & 255] ^ t2[(d >> 8) & 255] ^ t3[a & 255] ^ w[k + 1],
126
+ t0[c >> 24] ^ t1[(d >> 16) & 255] ^ t2[(a >> 8) & 255] ^ t3[b & 255] ^ w[k + 2],
127
+ t0[d >> 24] ^ t1[(a >> 16) & 255] ^ t2[(b >> 8) & 255] ^ t3[c & 255] ^ w[k + 3],
128
+ )
129
+ k += 4
130
+
131
+ def last(p: int, q: int, r: int, s: int, key: int) -> int:
132
+ return ((sbox[p >> 24] << 24) | (sbox[(q >> 16) & 255] << 16) | (sbox[(r >> 8) & 255] << 8) | sbox[s & 255]) ^ key
133
+
134
+ return (last(a, b, c, d, w[k]) << 96) | (last(b, c, d, a, w[k + 1]) << 64) | (last(c, d, a, b, w[k + 2]) << 32) | last(d, a, b, c, w[k + 3])
135
+
136
+
137
+ # GCM (NIST SP 800-38D).
138
+
139
+ _R = 0xE1 << 120
140
+ _MASK = (1 << 128) - 1
141
+
142
+
143
+ class _Gcm:
144
+ def __init__(self, key: bytes) -> None:
145
+ self.w = _expand(key)
146
+ h = _encrypt_block(self.w, 0)
147
+ # H times x^i for i in 0..127, so a product is the XOR of the rows its bits pick.
148
+ rows = []
149
+ v = h
150
+ for _ in range(128):
151
+ rows.append(v)
152
+ v = (v >> 1) ^ _R if v & 1 else v >> 1
153
+ self.rows = rows
154
+
155
+ def _mul(self, x: int) -> int:
156
+ out = 0
157
+ rows = self.rows
158
+ for i in range(128):
159
+ if x >> (127 - i) & 1:
160
+ out ^= rows[i]
161
+ return out
162
+
163
+ def _ghash(self, aad: bytes, data: bytes) -> int:
164
+ y = 0
165
+ for part in (aad, data):
166
+ for at in range(0, len(part), 16):
167
+ y = self._mul(y ^ int.from_bytes(part[at : at + 16].ljust(16, b"\0"), "big"))
168
+ return self._mul(y ^ ((len(aad) * 8) << 64) ^ (len(data) * 8))
169
+
170
+ def _j0(self, iv: bytes) -> int:
171
+ if len(iv) == 12:
172
+ return (int.from_bytes(iv, "big") << 32) | 1
173
+ return self._ghash(b"", iv)
174
+
175
+ def _ctr(self, j0: int, data: bytes) -> bytes:
176
+ out = bytearray()
177
+ counter = j0
178
+ for at in range(0, len(data), 16):
179
+ counter = (counter & ~0xFFFFFFFF) | ((counter + 1) & 0xFFFFFFFF)
180
+ stream = _encrypt_block(self.w, counter).to_bytes(16, "big")
181
+ chunk = data[at : at + 16]
182
+ out += (int.from_bytes(chunk, "big") ^ int.from_bytes(stream[: len(chunk)], "big")).to_bytes(len(chunk), "big")
183
+ return bytes(out)
184
+
185
+ def _tag(self, j0: int, aad: bytes, ciphertext: bytes) -> bytes:
186
+ return ((_encrypt_block(self.w, j0) ^ self._ghash(aad, ciphertext)) & _MASK).to_bytes(16, "big")
187
+
188
+ def seal(self, iv: bytes, data: bytes, aad: bytes) -> bytes:
189
+ j0 = self._j0(iv)
190
+ ciphertext = self._ctr(j0, data)
191
+ return ciphertext + self._tag(j0, aad, ciphertext)
192
+
193
+ def open(self, iv: bytes, data: bytes, aad: bytes) -> bytes:
194
+ ciphertext, tag = data[:-16], data[-16:]
195
+ j0 = self._j0(iv)
196
+ expected = self._tag(j0, aad, ciphertext)
197
+ diff = 0
198
+ for x, y in zip(expected, tag):
199
+ diff |= x ^ y
200
+ if diff:
201
+ raise InvalidTag("The tag does not match")
202
+ return self._ctr(j0, ciphertext)
runlight/_intl.py ADDED
@@ -0,0 +1,202 @@
1
+ """The pieces of JavaScript's Intl the email reports use, for the dashboard's languages (en, de, es, fr, and
2
+ pt): Intl.NumberFormat for counts, percents, one decimal place, and money, Intl.DateTimeFormat for a month and
3
+ year or a short day, and Intl.DisplayNames for a region's name. Python has no ICU, so region names, currency
4
+ symbols, and currency fraction digits come from data/intl.json, which scripts/python-intl.mts writes from
5
+ Node's own ICU.
6
+
7
+ Numbers round as ICU does, half away from zero on the number's shortest decimal form, so 2.05 to one
8
+ place is 2.1, though the double just under it is what is stored.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import math
15
+ import re
16
+ from functools import cache
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from . import _js
21
+
22
+ _GROUP = {"en": ",", "de": ".", "es": ".", "fr": " ", "pt": "."}
23
+ _DECIMAL = {"en": ".", "de": ",", "es": ",", "fr": ",", "pt": ","}
24
+ _PERCENT = {"en": "{}%", "de": "{} %", "es": "{} %", "fr": "{} %", "pt": "{}%"}
25
+
26
+ _MONTHS = {
27
+ "en": ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October",
28
+ "November", "December"],
29
+ "de": ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November",
30
+ "Dezember"],
31
+ "es": ["enero", "febrero", "marzo", "abril", "mayo", "junio", "julio", "agosto", "septiembre", "octubre",
32
+ "noviembre", "diciembre"],
33
+ "fr": ["janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août", "septembre", "octobre",
34
+ "novembre", "décembre"],
35
+ "pt": ["janeiro", "fevereiro", "março", "abril", "maio", "junho", "julho", "agosto", "setembro", "outubro",
36
+ "novembro", "dezembro"],
37
+ } # fmt: skip
38
+ _SHORT_MONTHS = {
39
+ "en": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
40
+ "de": ["Jan.", "Feb.", "März", "Apr.", "Mai", "Juni", "Juli", "Aug.", "Sept.", "Okt.", "Nov.", "Dez."],
41
+ "es": ["ene", "feb", "mar", "abr", "may", "jun", "jul", "ago", "sept", "oct", "nov", "dic"],
42
+ "fr": ["janv.", "févr.", "mars", "avr.", "mai", "juin", "juil.", "août", "sept.", "oct.", "nov.", "déc."],
43
+ "pt": ["jan.", "fev.", "mar.", "abr.", "mai.", "jun.", "jul.", "ago.", "set.", "out.", "nov.", "dez."],
44
+ }
45
+ # { month: "long", year: "numeric" }, then { month: "short", day: "numeric" } without and with the year.
46
+ _DATE_PATTERNS = {
47
+ "en": ["{M} {y}", "{m} {d}", "{m} {d}, {y}"],
48
+ "de": ["{M} {y}", "{d}. {m}", "{d}. {m} {y}"],
49
+ "es": ["{M} de {y}", "{d} {m}", "{d} {m} {y}"],
50
+ "fr": ["{M} {y}", "{d} {m}", "{d} {m} {y}"],
51
+ "pt": ["{M} de {y}", "{d} de {m}", "{d} de {m} de {y}"],
52
+ }
53
+
54
+ _THREES = re.compile(r"\B(?=(\d{3})+\Z)", re.ASCII)
55
+
56
+
57
+ @cache
58
+ def _data() -> dict[str, Any]:
59
+ return json.loads((Path(__file__).parent / "data" / "intl.json").read_text("utf-8"))
60
+
61
+
62
+ def _lang(lang: str) -> str:
63
+ return lang if lang in _GROUP else "en"
64
+
65
+
66
+ def number(lang: str, n: int | float, min_fraction: int = 0, max_fraction: int = 3) -> str:
67
+ """new Intl.NumberFormat(lang, { minimumFractionDigits, maximumFractionDigits }).format(n); the defaults are 0
68
+ and 3."""
69
+ lang = _lang(lang)
70
+ if isinstance(n, float) and math.isnan(n):
71
+ return "NaN"
72
+ if isinstance(n, float) and math.isinf(n):
73
+ return ("-" if n < 0 else "") + "∞"
74
+ negative, whole, fraction = _rounded(n, min_fraction, max_fraction)
75
+ # Spanish groups only from five digits on (CLDR's minimum grouping digits of 2).
76
+ if not (lang == "es" and len(whole) < 5):
77
+ whole = _THREES.sub(_GROUP[lang], whole)
78
+ return ("-" if negative else "") + whole + (_DECIMAL[lang] + fraction if fraction else "")
79
+
80
+
81
+ def percent(lang: str, n: int | float) -> str:
82
+ """new Intl.NumberFormat(lang, { style: "percent", maximumFractionDigits: 0 }).format(n)."""
83
+ lang = _lang(lang)
84
+ if isinstance(n, float) and not math.isfinite(n):
85
+ return _PERCENT[lang].format(number(lang, n))
86
+ return _PERCENT[lang].format(number(lang, _times100(n), 0, 0))
87
+
88
+
89
+ _CODE = re.compile(r"[A-Za-z]{3}\Z")
90
+
91
+
92
+ def currency(lang: str, n: int | float, code: str, max_fraction: int) -> str:
93
+ """new Intl.NumberFormat(lang, { style: "currency", currency, maximumFractionDigits }).format(n), or
94
+ `${n} ${currency}` where Intl throws (a currency code that is not three letters)."""
95
+ lang = _lang(lang)
96
+ if not _CODE.match(code):
97
+ return f"{_js.string(n)} {code}"
98
+ code = code.upper()
99
+ data = _data()
100
+ own = data["currencies"][lang].get(code)
101
+ if own is None:
102
+ before, after = (part.replace("{c}", code) for part in data["unknown"][lang])
103
+ else:
104
+ before, after = own
105
+ min_fraction = min(data["digits"].get(code, 2), max_fraction)
106
+ amount = number(lang, abs(n) if _js.is_finite(n) else n, min_fraction, max_fraction)
107
+ negative = n < 0 or (isinstance(n, float) and n == 0 and math.copysign(1, n) < 0)
108
+ if amount.startswith("-"):
109
+ amount = amount[1:]
110
+ return ("-" if negative else "") + before + amount + after
111
+
112
+
113
+ def month_year(lang: str, date: str) -> str:
114
+ """A date, YYYY-MM-DD, as { month: "long", year: "numeric" } writes it."""
115
+ return _date(lang, date, 0)
116
+
117
+
118
+ def short_day(lang: str, date: str, with_year: bool) -> str:
119
+ """A date as { month: "short", day: "numeric" } writes it, with `year: "numeric"` too when asked."""
120
+ return _date(lang, date, 2 if with_year else 1)
121
+
122
+
123
+ def _date(lang: str, date: str, pattern: int) -> str:
124
+ lang = _lang(lang)
125
+ y, m, d = (int(part) for part in date.split("-"))
126
+ return (
127
+ _DATE_PATTERNS[lang][pattern]
128
+ .replace("{M}", _MONTHS[lang][m - 1])
129
+ .replace("{m}", _SHORT_MONTHS[lang][m - 1])
130
+ .replace("{d}", str(d))
131
+ .replace("{y}", str(y))
132
+ )
133
+
134
+
135
+ _REGION = re.compile(r"([A-Z]{2}|[0-9]{3})\Z")
136
+
137
+
138
+ def region(lang: str, code: str) -> str:
139
+ """new Intl.DisplayNames(lang, { type: "region" }).of(code), or the code where that throws. Only an upper case
140
+ code is looked up; Intl gives any other back as it came."""
141
+ if not _REGION.match(code):
142
+ return code
143
+ return _data()["regions"][_lang(lang)].get(code, code)
144
+
145
+
146
+ def _times100(n: int | float) -> int | float:
147
+ """n * 100, worked out on the decimal digits, as ICU scales a percent, so 0.135 is 13.5 and not
148
+ 13.500000000000002."""
149
+ if isinstance(n, int):
150
+ return n * 100
151
+ negative, digits, point = _decimal(n)
152
+ return float(("-" if negative else "") + _plain(digits, point + 2))
153
+
154
+
155
+ def _rounded(n: int | float, min: int, max: int) -> tuple[bool, str, str]:
156
+ """The number's sign, whole digits, and fraction digits, rounded half away from zero to at most `max` places
157
+ and padded to at least `min`."""
158
+ if isinstance(n, int):
159
+ return n < 0, str(abs(n)), "0" * min
160
+ negative, digits, point = _decimal(n)
161
+ # Digits as a whole number of units of 10^-max.
162
+ keep = point + max
163
+ if keep < 0:
164
+ units = "0"
165
+ elif len(digits) > keep:
166
+ units = "0" if keep == 0 else digits[:keep]
167
+ if int(digits[keep]) >= 5:
168
+ units = str(int(units) + 1)
169
+ else:
170
+ units = digits.ljust(keep, "0")
171
+ units = units.rjust(max + 1, "0")
172
+ whole = units[: len(units) - max].lstrip("0")
173
+ fraction = (units[-max:] if max > 0 else "").rstrip("0")
174
+ fraction = fraction.ljust(min, "0")
175
+ return negative, whole or "0", fraction
176
+
177
+
178
+ def _decimal(n: float) -> tuple[bool, str, int]:
179
+ """The shortest decimal form of a double: its sign, its significant digits, and where the point goes (the
180
+ number of digits before it, which may be zero or negative)."""
181
+ negative = n < 0 or (n == 0 and math.copysign(1, n) < 0)
182
+ text = repr(abs(n))
183
+ exponent = 0
184
+ if "e" in text:
185
+ text, power = text.split("e")
186
+ exponent = int(power)
187
+ whole, _, fraction = text.partition(".")
188
+ digits = whole + fraction
189
+ point = len(whole) + exponent
190
+ trimmed = digits.lstrip("0")
191
+ point -= len(digits) - len(trimmed)
192
+ trimmed = trimmed.rstrip("0")
193
+ return negative, trimmed or "0", point if trimmed else 1
194
+
195
+
196
+ def _plain(digits: str, point: int) -> str:
197
+ """Digits with the point after `point` of them, written out in full."""
198
+ if point <= 0:
199
+ return "0." + "0" * -point + digits
200
+ if point >= len(digits):
201
+ return digits + "0" * (point - len(digits))
202
+ return digits[:point] + "." + digits[point:]