runlight 0.1.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 (98) hide show
  1. runlight-0.1.0/.gitignore +10 -0
  2. runlight-0.1.0/LICENSE +21 -0
  3. runlight-0.1.0/PKG-INFO +82 -0
  4. runlight-0.1.0/README.md +47 -0
  5. runlight-0.1.0/pyproject.toml +65 -0
  6. runlight-0.1.0/src/runlight/__init__.py +55 -0
  7. runlight-0.1.0/src/runlight/_aesgcm.py +202 -0
  8. runlight-0.1.0/src/runlight/_intl.py +202 -0
  9. runlight-0.1.0/src/runlight/_js.py +567 -0
  10. runlight-0.1.0/src/runlight/accounts/__init__.py +35 -0
  11. runlight-0.1.0/src/runlight/accounts/auth.py +738 -0
  12. runlight-0.1.0/src/runlight/accounts/crypto.py +152 -0
  13. runlight-0.1.0/src/runlight/accounts/pages.py +264 -0
  14. runlight-0.1.0/src/runlight/accounts/web.py +654 -0
  15. runlight-0.1.0/src/runlight/asgi.py +185 -0
  16. runlight-0.1.0/src/runlight/assets/build.json +10 -0
  17. runlight-0.1.0/src/runlight/assets/dashboard.css +1 -0
  18. runlight-0.1.0/src/runlight/assets/dashboard.js +81 -0
  19. runlight-0.1.0/src/runlight/assets/locales.json +7 -0
  20. runlight-0.1.0/src/runlight/assets/picker.js +1 -0
  21. runlight-0.1.0/src/runlight/assets/tracker.js +1 -0
  22. runlight-0.1.0/src/runlight/assets/world.json +1 -0
  23. runlight-0.1.0/src/runlight/assets.py +63 -0
  24. runlight-0.1.0/src/runlight/assistant.py +447 -0
  25. runlight-0.1.0/src/runlight/body.py +41 -0
  26. runlight-0.1.0/src/runlight/brand.py +14 -0
  27. runlight-0.1.0/src/runlight/connect.py +204 -0
  28. runlight-0.1.0/src/runlight/core.py +1469 -0
  29. runlight-0.1.0/src/runlight/data/__init__.py +1 -0
  30. runlight-0.1.0/src/runlight/data/agents.py +45 -0
  31. runlight-0.1.0/src/runlight/data/intl.json +1 -0
  32. runlight-0.1.0/src/runlight/data/sources.py +88 -0
  33. runlight-0.1.0/src/runlight/db.py +691 -0
  34. runlight-0.1.0/src/runlight/django/__init__.py +168 -0
  35. runlight-0.1.0/src/runlight/django/apps.py +11 -0
  36. runlight-0.1.0/src/runlight/django/management/__init__.py +0 -0
  37. runlight-0.1.0/src/runlight/django/management/commands/__init__.py +0 -0
  38. runlight-0.1.0/src/runlight/django/management/commands/runlight_check.py +15 -0
  39. runlight-0.1.0/src/runlight/env.py +16 -0
  40. runlight-0.1.0/src/runlight/fastapi.py +30 -0
  41. runlight-0.1.0/src/runlight/flask.py +48 -0
  42. runlight-0.1.0/src/runlight/funnels.py +56 -0
  43. runlight-0.1.0/src/runlight/geo.py +141 -0
  44. runlight-0.1.0/src/runlight/goals.py +141 -0
  45. runlight-0.1.0/src/runlight/hash.py +34 -0
  46. runlight-0.1.0/src/runlight/http/__init__.py +20 -0
  47. runlight-0.1.0/src/runlight/http/fetcher.py +200 -0
  48. runlight-0.1.0/src/runlight/http/headers.py +82 -0
  49. runlight-0.1.0/src/runlight/http/message.py +127 -0
  50. runlight-0.1.0/src/runlight/http/search_params.py +102 -0
  51. runlight-0.1.0/src/runlight/http/url.py +360 -0
  52. runlight-0.1.0/src/runlight/icon.py +177 -0
  53. runlight-0.1.0/src/runlight/importers/__init__.py +7 -0
  54. runlight-0.1.0/src/runlight/importers/bitly.py +99 -0
  55. runlight-0.1.0/src/runlight/importers/csvvisits.py +157 -0
  56. runlight-0.1.0/src/runlight/importers/dub.py +130 -0
  57. runlight-0.1.0/src/runlight/importers/http.py +282 -0
  58. runlight-0.1.0/src/runlight/importers/index.py +65 -0
  59. runlight-0.1.0/src/runlight/importers/rebrandly.py +51 -0
  60. runlight-0.1.0/src/runlight/importers/shortio.py +103 -0
  61. runlight-0.1.0/src/runlight/importers/types.py +49 -0
  62. runlight-0.1.0/src/runlight/importers/umami.py +146 -0
  63. runlight-0.1.0/src/runlight/importers/visits.py +400 -0
  64. runlight-0.1.0/src/runlight/importers/write.py +221 -0
  65. runlight-0.1.0/src/runlight/journeys.py +138 -0
  66. runlight-0.1.0/src/runlight/limit.py +46 -0
  67. runlight-0.1.0/src/runlight/links.py +159 -0
  68. runlight-0.1.0/src/runlight/mail/__init__.py +21 -0
  69. runlight-0.1.0/src/runlight/mail/secret.py +60 -0
  70. runlight-0.1.0/src/runlight/mail/ses.py +116 -0
  71. runlight-0.1.0/src/runlight/mail/smtp.py +299 -0
  72. runlight-0.1.0/src/runlight/mail/transports.py +268 -0
  73. runlight-0.1.0/src/runlight/mcp.py +342 -0
  74. runlight-0.1.0/src/runlight/messages.py +119 -0
  75. runlight-0.1.0/src/runlight/mmdb.py +208 -0
  76. runlight-0.1.0/src/runlight/oauth.py +463 -0
  77. runlight-0.1.0/src/runlight/payload.py +133 -0
  78. runlight-0.1.0/src/runlight/py.typed +0 -0
  79. runlight-0.1.0/src/runlight/query.py +81 -0
  80. runlight-0.1.0/src/runlight/reports.py +269 -0
  81. runlight-0.1.0/src/runlight/routes.py +2512 -0
  82. runlight-0.1.0/src/runlight/safefetch.py +264 -0
  83. runlight-0.1.0/src/runlight/serve.py +112 -0
  84. runlight-0.1.0/src/runlight/server/__init__.py +2 -0
  85. runlight-0.1.0/src/runlight/server/agents.py +504 -0
  86. runlight-0.1.0/src/runlight/server/cli.py +366 -0
  87. runlight-0.1.0/src/runlight/server/dbip.py +192 -0
  88. runlight-0.1.0/src/runlight/server/standalone.py +211 -0
  89. runlight-0.1.0/src/runlight/sources.py +173 -0
  90. runlight-0.1.0/src/runlight/store/__init__.py +7 -0
  91. runlight-0.1.0/src/runlight/store/sql.py +411 -0
  92. runlight-0.1.0/src/runlight/store/sql_store.py +1638 -0
  93. runlight-0.1.0/src/runlight/store/stores.py +73 -0
  94. runlight-0.1.0/src/runlight/time.py +360 -0
  95. runlight-0.1.0/src/runlight/ua.py +131 -0
  96. runlight-0.1.0/src/runlight/version.py +17 -0
  97. runlight-0.1.0/src/runlight/wsgi.py +190 -0
  98. runlight-0.1.0/src/runlight/zip.py +75 -0
@@ -0,0 +1,10 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ .mypy_cache/
7
+ dist/
8
+ build/
9
+ *.egg-info/
10
+ uv.lock
runlight-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jon Phillips
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.5
2
+ Name: runlight
3
+ Version: 0.1.0
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
+ Project-URL: Homepage, https://runlight.sh
6
+ Project-URL: Documentation, https://runlight.sh/docs/python/
7
+ Project-URL: Source, https://github.com/runlightsh/runlight/tree/main/packages/python
8
+ Project-URL: Issues, https://github.com/runlightsh/runlight/issues
9
+ Author: Jon C. Phillips
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: analytics,django,fastapi,flask,privacy,short links,web analytics
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Framework :: Django
15
+ Classifier: Framework :: FastAPI
16
+ Classifier: Framework :: Flask
17
+ Classifier: Intended Audience :: Developers
18
+ Classifier: Operating System :: OS Independent
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3 :: Only
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Programming Language :: Python :: 3.14
25
+ Classifier: Topic :: Internet :: Log Analysis
26
+ Classifier: Typing :: Typed
27
+ Requires-Python: >=3.11
28
+ Provides-Extra: mysql
29
+ Requires-Dist: pymysql>=1.1; extra == 'mysql'
30
+ Provides-Extra: postgres
31
+ Requires-Dist: psycopg>=3.2; extra == 'postgres'
32
+ Provides-Extra: tzdata
33
+ Requires-Dist: tzdata; extra == 'tzdata'
34
+ Description-Content-Type: text/markdown
35
+
36
+ # runlight
37
+
38
+ Runlight is privacy friendly web analytics that runs inside your own Python app. It counts visitors without cookies and without storing anyone’s IP address. The numbers stay in your database, and the dashboard is served from your domain at `/runlight`.
39
+
40
+ This package is the Python version of [Runlight](https://runlight.sh). It works in Django, Flask, FastAPI, or any WSGI or ASGI app on Python 3.11 or later, and it can also run on its own domain as a standalone server. It answers every request the way the TypeScript library does and uses the same tables, so either one can read the other’s database.
41
+
42
+ ## Get started
43
+
44
+ Install it with pip.
45
+
46
+ ```bash
47
+ pip install runlight
48
+ ```
49
+
50
+ Create one instance for your app.
51
+
52
+ ```python
53
+ from runlight import Runlight
54
+ from runlight.store import Stores
55
+
56
+ rl = Runlight(
57
+ store=Stores.sqlite("data/runlight.db"),
58
+ site={"name": "example.com", "hostnames": ["example.com"], "timezone": "Europe/London"},
59
+ )
60
+ ```
61
+
62
+ Put it in front of your app. In Flask that is one line.
63
+
64
+ ```python
65
+ import runlight.flask
66
+
67
+ runlight.flask.init_app(app, rl)
68
+ ```
69
+
70
+ Add the script to every page, just before `</head>`.
71
+
72
+ ```html
73
+ <script defer src="/runlight/s.js"></script>
74
+ ```
75
+
76
+ Set `RUNLIGHT_TOKEN` to a long random string, then open `/runlight/?token=` followed by that string to sign in.
77
+
78
+ The [Python guide](https://runlight.sh/docs/python/) has the code for Django, FastAPI, and plain WSGI or ASGI apps, the Postgres, MySQL, and MariaDB stores, the scheduled check, and the standalone server.
79
+
80
+ ## License
81
+
82
+ Runlight is MIT licensed.
@@ -0,0 +1,47 @@
1
+ # runlight
2
+
3
+ Runlight is privacy friendly web analytics that runs inside your own Python app. It counts visitors without cookies and without storing anyone’s IP address. The numbers stay in your database, and the dashboard is served from your domain at `/runlight`.
4
+
5
+ This package is the Python version of [Runlight](https://runlight.sh). It works in Django, Flask, FastAPI, or any WSGI or ASGI app on Python 3.11 or later, and it can also run on its own domain as a standalone server. It answers every request the way the TypeScript library does and uses the same tables, so either one can read the other’s database.
6
+
7
+ ## Get started
8
+
9
+ Install it with pip.
10
+
11
+ ```bash
12
+ pip install runlight
13
+ ```
14
+
15
+ Create one instance for your app.
16
+
17
+ ```python
18
+ from runlight import Runlight
19
+ from runlight.store import Stores
20
+
21
+ rl = Runlight(
22
+ store=Stores.sqlite("data/runlight.db"),
23
+ site={"name": "example.com", "hostnames": ["example.com"], "timezone": "Europe/London"},
24
+ )
25
+ ```
26
+
27
+ Put it in front of your app. In Flask that is one line.
28
+
29
+ ```python
30
+ import runlight.flask
31
+
32
+ runlight.flask.init_app(app, rl)
33
+ ```
34
+
35
+ Add the script to every page, just before `</head>`.
36
+
37
+ ```html
38
+ <script defer src="/runlight/s.js"></script>
39
+ ```
40
+
41
+ Set `RUNLIGHT_TOKEN` to a long random string, then open `/runlight/?token=` followed by that string to sign in.
42
+
43
+ The [Python guide](https://runlight.sh/docs/python/) has the code for Django, FastAPI, and plain WSGI or ASGI apps, the Postgres, MySQL, and MariaDB stores, the scheduled check, and the standalone server.
44
+
45
+ ## License
46
+
47
+ Runlight is MIT licensed.
@@ -0,0 +1,65 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "runlight"
7
+ version = "0.1.0"
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
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.11"
13
+ authors = [{ name = "Jon C. Phillips" }]
14
+ keywords = ["analytics", "privacy", "web analytics", "short links", "django", "flask", "fastapi"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Framework :: Django",
18
+ "Framework :: Flask",
19
+ "Framework :: FastAPI",
20
+ "Intended Audience :: Developers",
21
+ "Operating System :: OS Independent",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3 :: Only",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Programming Language :: Python :: 3.13",
27
+ "Programming Language :: Python :: 3.14",
28
+ "Topic :: Internet :: Log Analysis",
29
+ "Typing :: Typed",
30
+ ]
31
+ # The core has no dependencies: SQLite, HTTP, crypto, and zones come from the standard library.
32
+ dependencies = []
33
+
34
+ [project.optional-dependencies]
35
+ # zoneinfo reads the system's zone database; Windows has none, so install this there.
36
+ tzdata = ["tzdata"]
37
+ # Stores.postgres(), on psycopg 3.
38
+ postgres = ["psycopg>=3.2"]
39
+ # Stores.mysql(), for MySQL 8.4 and MariaDB 11.4 and later.
40
+ mysql = ["pymysql>=1.1"]
41
+
42
+ [project.scripts]
43
+ runlight = "runlight.server.cli:main"
44
+
45
+ [project.urls]
46
+ Homepage = "https://runlight.sh"
47
+ Documentation = "https://runlight.sh/docs/python/"
48
+ Source = "https://github.com/runlightsh/runlight/tree/main/packages/python"
49
+ Issues = "https://github.com/runlightsh/runlight/issues"
50
+
51
+ [dependency-groups]
52
+ # psycopg and PyMySQL for the Postgres and MySQL tests (run when RUNLIGHT_TEST_PG
53
+ # or RUNLIGHT_TEST_MYSQL is set), and Django, Flask, FastAPI and httpx for the
54
+ # framework adapters' tests.
55
+ dev = ["pytest>=8", "psycopg[binary]>=3.2", "pymysql>=1.1", "django>=5.2", "flask>=3", "fastapi>=0.115", "httpx>=0.27"]
56
+
57
+ [tool.hatch.build.targets.wheel]
58
+ packages = ["src/runlight"]
59
+
60
+ [tool.hatch.build.targets.sdist]
61
+ include = ["src/runlight", "README.md", "LICENSE", "pyproject.toml"]
62
+
63
+ [tool.pytest.ini_options]
64
+ testpaths = ["tests"]
65
+ addopts = "-ra"
@@ -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)
@@ -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)