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.
- runlight/__init__.py +55 -0
- runlight/_aesgcm.py +202 -0
- runlight/_intl.py +202 -0
- runlight/_js.py +567 -0
- runlight/accounts/__init__.py +35 -0
- runlight/accounts/auth.py +738 -0
- runlight/accounts/crypto.py +152 -0
- runlight/accounts/pages.py +264 -0
- runlight/accounts/web.py +654 -0
- runlight/asgi.py +185 -0
- runlight/assets/build.json +10 -0
- runlight/assets/dashboard.css +1 -0
- runlight/assets/dashboard.js +81 -0
- runlight/assets/locales.json +7 -0
- runlight/assets/picker.js +1 -0
- runlight/assets/tracker.js +1 -0
- runlight/assets/world.json +1 -0
- runlight/assets.py +63 -0
- runlight/assistant.py +447 -0
- runlight/body.py +41 -0
- runlight/brand.py +14 -0
- runlight/connect.py +204 -0
- runlight/core.py +1469 -0
- runlight/data/__init__.py +1 -0
- runlight/data/agents.py +45 -0
- runlight/data/intl.json +1 -0
- runlight/data/sources.py +88 -0
- runlight/db.py +691 -0
- runlight/django/__init__.py +168 -0
- runlight/django/apps.py +11 -0
- runlight/django/management/__init__.py +0 -0
- runlight/django/management/commands/__init__.py +0 -0
- runlight/django/management/commands/runlight_check.py +15 -0
- runlight/env.py +16 -0
- runlight/fastapi.py +30 -0
- runlight/flask.py +48 -0
- runlight/funnels.py +56 -0
- runlight/geo.py +141 -0
- runlight/goals.py +141 -0
- runlight/hash.py +34 -0
- runlight/http/__init__.py +20 -0
- runlight/http/fetcher.py +200 -0
- runlight/http/headers.py +82 -0
- runlight/http/message.py +127 -0
- runlight/http/search_params.py +102 -0
- runlight/http/url.py +360 -0
- runlight/icon.py +177 -0
- runlight/importers/__init__.py +7 -0
- runlight/importers/bitly.py +99 -0
- runlight/importers/csvvisits.py +157 -0
- runlight/importers/dub.py +130 -0
- runlight/importers/http.py +282 -0
- runlight/importers/index.py +65 -0
- runlight/importers/rebrandly.py +51 -0
- runlight/importers/shortio.py +103 -0
- runlight/importers/types.py +49 -0
- runlight/importers/umami.py +146 -0
- runlight/importers/visits.py +400 -0
- runlight/importers/write.py +221 -0
- runlight/journeys.py +138 -0
- runlight/limit.py +46 -0
- runlight/links.py +159 -0
- runlight/mail/__init__.py +21 -0
- runlight/mail/secret.py +60 -0
- runlight/mail/ses.py +116 -0
- runlight/mail/smtp.py +299 -0
- runlight/mail/transports.py +268 -0
- runlight/mcp.py +342 -0
- runlight/messages.py +119 -0
- runlight/mmdb.py +208 -0
- runlight/oauth.py +463 -0
- runlight/payload.py +133 -0
- runlight/py.typed +0 -0
- runlight/query.py +81 -0
- runlight/reports.py +269 -0
- runlight/routes.py +2512 -0
- runlight/safefetch.py +264 -0
- runlight/serve.py +112 -0
- runlight/server/__init__.py +2 -0
- runlight/server/agents.py +504 -0
- runlight/server/cli.py +366 -0
- runlight/server/dbip.py +192 -0
- runlight/server/standalone.py +211 -0
- runlight/sources.py +173 -0
- runlight/store/__init__.py +7 -0
- runlight/store/sql.py +411 -0
- runlight/store/sql_store.py +1638 -0
- runlight/store/stores.py +73 -0
- runlight/time.py +360 -0
- runlight/ua.py +131 -0
- runlight/version.py +17 -0
- runlight/wsgi.py +190 -0
- runlight/zip.py +75 -0
- runlight-0.1.0.dist-info/METADATA +82 -0
- runlight-0.1.0.dist-info/RECORD +98 -0
- runlight-0.1.0.dist-info/WHEEL +4 -0
- runlight-0.1.0.dist-info/entry_points.txt +2 -0
- 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:]
|