raidiantai-analytics 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.
- raidiantai_analytics-0.1.0/CHANGELOG.md +5 -0
- raidiantai_analytics-0.1.0/LICENSE +21 -0
- raidiantai_analytics-0.1.0/PKG-INFO +123 -0
- raidiantai_analytics-0.1.0/README.md +108 -0
- raidiantai_analytics-0.1.0/pyproject.toml +48 -0
- raidiantai_analytics-0.1.0/setup.cfg +4 -0
- raidiantai_analytics-0.1.0/src/raidiantai_analytics/__init__.py +42 -0
- raidiantai_analytics-0.1.0/src/raidiantai_analytics/_admission.py +218 -0
- raidiantai_analytics-0.1.0/src/raidiantai_analytics/_client.py +2163 -0
- raidiantai_analytics-0.1.0/src/raidiantai_analytics/_config.py +311 -0
- raidiantai_analytics-0.1.0/src/raidiantai_analytics/_contract.py +407 -0
- raidiantai_analytics-0.1.0/src/raidiantai_analytics/_diagnostics.py +215 -0
- raidiantai_analytics-0.1.0/src/raidiantai_analytics/_identity.py +124 -0
- raidiantai_analytics-0.1.0/src/raidiantai_analytics/_locks.py +195 -0
- raidiantai_analytics-0.1.0/src/raidiantai_analytics/_pool.py +334 -0
- raidiantai_analytics-0.1.0/src/raidiantai_analytics/_prepare.py +52 -0
- raidiantai_analytics-0.1.0/src/raidiantai_analytics/_retry_after.py +154 -0
- raidiantai_analytics-0.1.0/src/raidiantai_analytics/_runtime.py +61 -0
- raidiantai_analytics-0.1.0/src/raidiantai_analytics/_serialize.py +139 -0
- raidiantai_analytics-0.1.0/src/raidiantai_analytics/_text.py +136 -0
- raidiantai_analytics-0.1.0/src/raidiantai_analytics/_transport.py +375 -0
- raidiantai_analytics-0.1.0/src/raidiantai_analytics/_validate.py +229 -0
- raidiantai_analytics-0.1.0/src/raidiantai_analytics/_version.py +4 -0
- raidiantai_analytics-0.1.0/src/raidiantai_analytics/py.typed +0 -0
- raidiantai_analytics-0.1.0/src/raidiantai_analytics.egg-info/PKG-INFO +123 -0
- raidiantai_analytics-0.1.0/src/raidiantai_analytics.egg-info/SOURCES.txt +26 -0
- raidiantai_analytics-0.1.0/src/raidiantai_analytics.egg-info/dependency_links.txt +1 -0
- raidiantai_analytics-0.1.0/src/raidiantai_analytics.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Raidiant LLC
|
|
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,123 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: raidiantai-analytics
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Send a tool's usage to Raidiant AI from a Python server, with a tool key.
|
|
5
|
+
Author: Raidiant LLC
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Typing :: Typed
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# raidiantai-analytics
|
|
17
|
+
|
|
18
|
+
Send your tool's usage to Basecamp from a Python server or script, with a tool key.
|
|
19
|
+
|
|
20
|
+
**Python 3.10 or later. Standard library only; no runtime dependencies. Server
|
|
21
|
+
only:** it sends with a tool key, which is a server-side secret. Versions before
|
|
22
|
+
1.0 may change; see the changelog.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install raidiantai-analytics
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Quickstart
|
|
31
|
+
|
|
32
|
+
You need a tool key and your company's Basecamp address (for example
|
|
33
|
+
`https://<your-basecamp-address>`). This reads them from the environment as
|
|
34
|
+
`BASECAMP_TOOL_KEY` and `BASECAMP_URL`. Use a test key while you build: it is
|
|
35
|
+
checked and answered like a live key, and nothing is counted.
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
import os
|
|
39
|
+
|
|
40
|
+
from raidiantai_analytics import Analytics, CheckKeyOk, Diagnostic, email_identity
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def show(d: Diagnostic) -> None:
|
|
44
|
+
print(f"analytics: {d.code}" + (f" ({d.reason})" if d.reason else ""))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# One client per process. It never raises: a missing or malformed option
|
|
48
|
+
# makes it send nothing and is reported through on_diagnostic.
|
|
49
|
+
analytics = Analytics(
|
|
50
|
+
key=os.environ.get("BASECAMP_TOOL_KEY"),
|
|
51
|
+
base_url=os.environ.get("BASECAMP_URL"),
|
|
52
|
+
# Without this, most problems would only be counted, not shown.
|
|
53
|
+
on_diagnostic=show,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Check the key first, so a wrong, stopped or switched-off key shows up now
|
|
57
|
+
# instead of as usage that never arrives.
|
|
58
|
+
check = analytics.check_key()
|
|
59
|
+
if not isinstance(check, CheckKeyOk):
|
|
60
|
+
print(f"The key check failed: {check.code}")
|
|
61
|
+
elif check.status != "active" or not check.integrations_enabled:
|
|
62
|
+
print("The key is stopped, or integrations are off for your company.")
|
|
63
|
+
else:
|
|
64
|
+
print(f"Sending for {check.tool_name} with a {check.mode} key.")
|
|
65
|
+
|
|
66
|
+
# Once per meaningful completion of your tool's main job, for the person it was for.
|
|
67
|
+
identity = email_identity("alice@example.com")
|
|
68
|
+
if identity.ok:
|
|
69
|
+
analytics.for_actor(identity.actor).capture_use()
|
|
70
|
+
|
|
71
|
+
# Send what is queued. `remaining` is how many events are still waiting.
|
|
72
|
+
result = analytics.flush()
|
|
73
|
+
print(f"{result.remaining} events still queued.")
|
|
74
|
+
|
|
75
|
+
# This script is about to exit, so shut the client down.
|
|
76
|
+
analytics.shutdown()
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
- Call `capture_use()` once per meaningful completion of your tool's main job.
|
|
80
|
+
Use `capture_custom(name, properties=...)` for anything else; it never counts.
|
|
81
|
+
- Pass the `event_id` of your own work (`capture_use(event_id=...)`) when it can
|
|
82
|
+
repeat: a repeat with the same ID never counts twice.
|
|
83
|
+
- Pass an email your application has verified.
|
|
84
|
+
|
|
85
|
+
## Sending and shutting down
|
|
86
|
+
|
|
87
|
+
Events are sent by a background thread, in batches. The thread never keeps the
|
|
88
|
+
interpreter alive.
|
|
89
|
+
|
|
90
|
+
- `analytics.flush()` sends what is queued and returns a result whose
|
|
91
|
+
`remaining` is how many events are still queued. `0` means the queue is empty,
|
|
92
|
+
not that everything arrived (refused or given-up events leave it too; see
|
|
93
|
+
`get_stats()`). Otherwise it is the number still queued when its deadline passed (10 seconds by default). Those events are not
|
|
94
|
+
lost; the client keeps trying. In serverless code, call `flush()` before your
|
|
95
|
+
handler returns.
|
|
96
|
+
- `analytics.shutdown()` belongs **only where the process is about to exit**
|
|
97
|
+
(or use the client as a context manager, `with Analytics(...) as analytics:`).
|
|
98
|
+
It stops accepting events, sends what it can for up to 5 seconds, then
|
|
99
|
+
discards the rest; its `remaining` is how many it discarded. After it, every
|
|
100
|
+
capture is dropped. The package registers no `atexit` handler of its own.
|
|
101
|
+
|
|
102
|
+
The queue is in memory, so a restart or crash loses what was still queued.
|
|
103
|
+
|
|
104
|
+
## Keep the key secret
|
|
105
|
+
|
|
106
|
+
- The key is a **server-side** secret. Read it from the server's environment or
|
|
107
|
+
a secret store; never ship it inside an app your users run.
|
|
108
|
+
- **Never commit** it to a repository.
|
|
109
|
+
- If it may have been exposed, **rotate** it at once: cancel it in Basecamp and
|
|
110
|
+
make a new one.
|
|
111
|
+
|
|
112
|
+
## Your key and the full guide
|
|
113
|
+
|
|
114
|
+
Both need a Basecamp sign-in:
|
|
115
|
+
|
|
116
|
+
- **Your tool key:** your tool's page in Basecamp, on its **Integration** tab.
|
|
117
|
+
- **The full guide:** `https://<your-basecamp-address>/docs/developers/sdk-python`,
|
|
118
|
+
covering what counts as a use, what can be lost and when, privacy, property
|
|
119
|
+
values, pausing, the network and every option. For help, start there.
|
|
120
|
+
|
|
121
|
+
## Licence
|
|
122
|
+
|
|
123
|
+
MIT. Copyright (c) 2026 Raidiant LLC. See `LICENSE`.
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# raidiantai-analytics
|
|
2
|
+
|
|
3
|
+
Send your tool's usage to Basecamp from a Python server or script, with a tool key.
|
|
4
|
+
|
|
5
|
+
**Python 3.10 or later. Standard library only; no runtime dependencies. Server
|
|
6
|
+
only:** it sends with a tool key, which is a server-side secret. Versions before
|
|
7
|
+
1.0 may change; see the changelog.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install raidiantai-analytics
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Quickstart
|
|
16
|
+
|
|
17
|
+
You need a tool key and your company's Basecamp address (for example
|
|
18
|
+
`https://<your-basecamp-address>`). This reads them from the environment as
|
|
19
|
+
`BASECAMP_TOOL_KEY` and `BASECAMP_URL`. Use a test key while you build: it is
|
|
20
|
+
checked and answered like a live key, and nothing is counted.
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
import os
|
|
24
|
+
|
|
25
|
+
from raidiantai_analytics import Analytics, CheckKeyOk, Diagnostic, email_identity
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def show(d: Diagnostic) -> None:
|
|
29
|
+
print(f"analytics: {d.code}" + (f" ({d.reason})" if d.reason else ""))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# One client per process. It never raises: a missing or malformed option
|
|
33
|
+
# makes it send nothing and is reported through on_diagnostic.
|
|
34
|
+
analytics = Analytics(
|
|
35
|
+
key=os.environ.get("BASECAMP_TOOL_KEY"),
|
|
36
|
+
base_url=os.environ.get("BASECAMP_URL"),
|
|
37
|
+
# Without this, most problems would only be counted, not shown.
|
|
38
|
+
on_diagnostic=show,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Check the key first, so a wrong, stopped or switched-off key shows up now
|
|
42
|
+
# instead of as usage that never arrives.
|
|
43
|
+
check = analytics.check_key()
|
|
44
|
+
if not isinstance(check, CheckKeyOk):
|
|
45
|
+
print(f"The key check failed: {check.code}")
|
|
46
|
+
elif check.status != "active" or not check.integrations_enabled:
|
|
47
|
+
print("The key is stopped, or integrations are off for your company.")
|
|
48
|
+
else:
|
|
49
|
+
print(f"Sending for {check.tool_name} with a {check.mode} key.")
|
|
50
|
+
|
|
51
|
+
# Once per meaningful completion of your tool's main job, for the person it was for.
|
|
52
|
+
identity = email_identity("alice@example.com")
|
|
53
|
+
if identity.ok:
|
|
54
|
+
analytics.for_actor(identity.actor).capture_use()
|
|
55
|
+
|
|
56
|
+
# Send what is queued. `remaining` is how many events are still waiting.
|
|
57
|
+
result = analytics.flush()
|
|
58
|
+
print(f"{result.remaining} events still queued.")
|
|
59
|
+
|
|
60
|
+
# This script is about to exit, so shut the client down.
|
|
61
|
+
analytics.shutdown()
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
- Call `capture_use()` once per meaningful completion of your tool's main job.
|
|
65
|
+
Use `capture_custom(name, properties=...)` for anything else; it never counts.
|
|
66
|
+
- Pass the `event_id` of your own work (`capture_use(event_id=...)`) when it can
|
|
67
|
+
repeat: a repeat with the same ID never counts twice.
|
|
68
|
+
- Pass an email your application has verified.
|
|
69
|
+
|
|
70
|
+
## Sending and shutting down
|
|
71
|
+
|
|
72
|
+
Events are sent by a background thread, in batches. The thread never keeps the
|
|
73
|
+
interpreter alive.
|
|
74
|
+
|
|
75
|
+
- `analytics.flush()` sends what is queued and returns a result whose
|
|
76
|
+
`remaining` is how many events are still queued. `0` means the queue is empty,
|
|
77
|
+
not that everything arrived (refused or given-up events leave it too; see
|
|
78
|
+
`get_stats()`). Otherwise it is the number still queued when its deadline passed (10 seconds by default). Those events are not
|
|
79
|
+
lost; the client keeps trying. In serverless code, call `flush()` before your
|
|
80
|
+
handler returns.
|
|
81
|
+
- `analytics.shutdown()` belongs **only where the process is about to exit**
|
|
82
|
+
(or use the client as a context manager, `with Analytics(...) as analytics:`).
|
|
83
|
+
It stops accepting events, sends what it can for up to 5 seconds, then
|
|
84
|
+
discards the rest; its `remaining` is how many it discarded. After it, every
|
|
85
|
+
capture is dropped. The package registers no `atexit` handler of its own.
|
|
86
|
+
|
|
87
|
+
The queue is in memory, so a restart or crash loses what was still queued.
|
|
88
|
+
|
|
89
|
+
## Keep the key secret
|
|
90
|
+
|
|
91
|
+
- The key is a **server-side** secret. Read it from the server's environment or
|
|
92
|
+
a secret store; never ship it inside an app your users run.
|
|
93
|
+
- **Never commit** it to a repository.
|
|
94
|
+
- If it may have been exposed, **rotate** it at once: cancel it in Basecamp and
|
|
95
|
+
make a new one.
|
|
96
|
+
|
|
97
|
+
## Your key and the full guide
|
|
98
|
+
|
|
99
|
+
Both need a Basecamp sign-in:
|
|
100
|
+
|
|
101
|
+
- **Your tool key:** your tool's page in Basecamp, on its **Integration** tab.
|
|
102
|
+
- **The full guide:** `https://<your-basecamp-address>/docs/developers/sdk-python`,
|
|
103
|
+
covering what counts as a use, what can be lost and when, privacy, property
|
|
104
|
+
values, pausing, the network and every option. For help, start there.
|
|
105
|
+
|
|
106
|
+
## Licence
|
|
107
|
+
|
|
108
|
+
MIT. Copyright (c) 2026 Raidiant LLC. See `LICENSE`.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "raidiantai-analytics"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Send a tool's usage to Raidiant AI from a Python server, with a tool key."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Raidiant LLC" }]
|
|
14
|
+
dependencies = []
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Typing :: Typed",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[tool.setuptools.dynamic]
|
|
23
|
+
version = { attr = "raidiantai_analytics._version.__version__" }
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.packages.find]
|
|
26
|
+
where = ["src"]
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.package-data]
|
|
29
|
+
raidiantai_analytics = ["py.typed"]
|
|
30
|
+
|
|
31
|
+
[tool.ruff]
|
|
32
|
+
target-version = "py310"
|
|
33
|
+
line-length = 120
|
|
34
|
+
|
|
35
|
+
[tool.ruff.lint]
|
|
36
|
+
select = ["E", "F", "W", "B", "UP", "I"]
|
|
37
|
+
ignore = ["B904"]
|
|
38
|
+
|
|
39
|
+
[tool.ruff.lint.per-file-ignores]
|
|
40
|
+
"src/raidiantai_analytics/_contract.py" = ["E501"]
|
|
41
|
+
# The test suite's directory is written as the glob "test[s]" because this file ships
|
|
42
|
+
# in the sdist, whose public-hygiene check refuses a plain test-directory path.
|
|
43
|
+
"test[s]/**" = ["E501", "B011", "B017"]
|
|
44
|
+
|
|
45
|
+
[tool.mypy]
|
|
46
|
+
python_version = "3.10"
|
|
47
|
+
strict = true
|
|
48
|
+
files = ["src/raidiantai_analytics"]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Send a tool's usage to Raidiant AI from a Python server, with a tool key.
|
|
2
|
+
|
|
3
|
+
Standard library only. Python 3.10 or later.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from ._client import (
|
|
7
|
+
ActorHandle,
|
|
8
|
+
Analytics,
|
|
9
|
+
CaptureDropped,
|
|
10
|
+
CaptureInvalid,
|
|
11
|
+
CaptureQueued,
|
|
12
|
+
CaptureResult,
|
|
13
|
+
CheckKeyFailed,
|
|
14
|
+
CheckKeyOk,
|
|
15
|
+
CheckKeyResult,
|
|
16
|
+
DrainResult,
|
|
17
|
+
Stats,
|
|
18
|
+
)
|
|
19
|
+
from ._diagnostics import Diagnostic
|
|
20
|
+
from ._identity import Actor, IdentityOk, IdentityRefused, email_identity
|
|
21
|
+
from ._version import PACKAGE_NAME, __version__
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"PACKAGE_NAME",
|
|
25
|
+
"Actor",
|
|
26
|
+
"ActorHandle",
|
|
27
|
+
"Analytics",
|
|
28
|
+
"CaptureDropped",
|
|
29
|
+
"CaptureInvalid",
|
|
30
|
+
"CaptureQueued",
|
|
31
|
+
"CaptureResult",
|
|
32
|
+
"CheckKeyFailed",
|
|
33
|
+
"CheckKeyOk",
|
|
34
|
+
"CheckKeyResult",
|
|
35
|
+
"Diagnostic",
|
|
36
|
+
"DrainResult",
|
|
37
|
+
"IdentityOk",
|
|
38
|
+
"IdentityRefused",
|
|
39
|
+
"Stats",
|
|
40
|
+
"__version__",
|
|
41
|
+
"email_identity",
|
|
42
|
+
]
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Host admission: may this host value become wire text at all?
|
|
2
|
+
|
|
3
|
+
One iterative traversal that copies the host's value into a snapshot (each
|
|
4
|
+
member read once, through the base type's own methods, so no method a host
|
|
5
|
+
subclass overrides runs), admitting exactly None, bool, an int (not bool) with
|
|
6
|
+
|n| <= 2^53, a finite float, a str with no high-then-low surrogate pair, a
|
|
7
|
+
list or tuple, and a dict whose keys are all str. int, float and str
|
|
8
|
+
subclasses are admitted and copied as their base type. Everything else is
|
|
9
|
+
refused as `invalid_event` at its path. Two representability limits apply
|
|
10
|
+
during the same walk: nesting depth (the event is depth 1) and a running size
|
|
11
|
+
count L, a strict lower bound on the canonical text's UTF-8 bytes, refused as
|
|
12
|
+
`event_too_large` at "" once it passes the ceiling.
|
|
13
|
+
|
|
14
|
+
L accounting, in traversal order (pre-order, an object's key visited just
|
|
15
|
+
before its member, members in ECMAScript key order): every value adds 1, plus
|
|
16
|
+
its code points if it is a str; a container adds its member count when it is
|
|
17
|
+
entered, before any of its members are read; every key adds its code points.
|
|
18
|
+
Inside one value the checks run: size, then type, then depth, then the member
|
|
19
|
+
count. The snapshot holds only exact types, its dicts already in ECMAScript
|
|
20
|
+
key order.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import math
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from . import _text
|
|
30
|
+
from ._contract import CONTRACT
|
|
31
|
+
|
|
32
|
+
ADMISSION_SIZE_CEILING: int = CONTRACT["sdk_limits"]["ADMISSION_SIZE_CEILING"]
|
|
33
|
+
ADMISSION_DEPTH_CEILING: int = CONTRACT["sdk_limits"]["ADMISSION_DEPTH_CEILING"]
|
|
34
|
+
_MAX_SAFE = 2**53
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class AdmissionRefusal:
|
|
39
|
+
reason: str # "invalid_event" | "event_too_large"
|
|
40
|
+
path: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class Admitted:
|
|
45
|
+
value: Any
|
|
46
|
+
count: int
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class Refused:
|
|
51
|
+
refusal: AdmissionRefusal
|
|
52
|
+
count: int
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class _Probe:
|
|
56
|
+
"""The most recent traversal's final count, for tests that measure where the walk stopped."""
|
|
57
|
+
|
|
58
|
+
last_count = 0
|
|
59
|
+
strings_scanned = 0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
admission_probe = _Probe()
|
|
63
|
+
|
|
64
|
+
# Work items. ("value", value, path, depth, setter); ("key", key); setter writes the copy.
|
|
65
|
+
_VALUE = 0
|
|
66
|
+
_KEY = 1
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def admit(root: Any) -> Admitted | Refused:
|
|
70
|
+
"""Admit and snapshot `root` (the whole event). Never recurses and never calls host code."""
|
|
71
|
+
count = 0
|
|
72
|
+
ceiling = ADMISSION_SIZE_CEILING
|
|
73
|
+
holder: list[Any] = [None]
|
|
74
|
+
|
|
75
|
+
def set_root(x: Any) -> None:
|
|
76
|
+
holder[0] = x
|
|
77
|
+
|
|
78
|
+
stack: list[tuple[Any, ...]] = [(_VALUE, root, "", 1, set_root)]
|
|
79
|
+
|
|
80
|
+
def too_large() -> Refused:
|
|
81
|
+
admission_probe.last_count = count
|
|
82
|
+
return Refused(AdmissionRefusal("event_too_large", ""), count)
|
|
83
|
+
|
|
84
|
+
def invalid(path: str) -> Refused:
|
|
85
|
+
admission_probe.last_count = count
|
|
86
|
+
return Refused(AdmissionRefusal("invalid_event", path), count)
|
|
87
|
+
|
|
88
|
+
while stack:
|
|
89
|
+
item = stack.pop()
|
|
90
|
+
if item[0] == _KEY:
|
|
91
|
+
# A key that is not a str, or a str holding a surrogate pair, is the
|
|
92
|
+
# holding object's fault: its path is the holder's.
|
|
93
|
+
_, k, holder_path = item
|
|
94
|
+
if k is _NOT_A_STR:
|
|
95
|
+
return invalid(holder_path)
|
|
96
|
+
n = len(k)
|
|
97
|
+
if n > ceiling - count:
|
|
98
|
+
count = ceiling + 1
|
|
99
|
+
return too_large()
|
|
100
|
+
count += n
|
|
101
|
+
admission_probe.strings_scanned += 1
|
|
102
|
+
if _text.has_surrogate_pair(k):
|
|
103
|
+
return invalid(holder_path)
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
_, v, path, depth, setter = item
|
|
107
|
+
t = type(v)
|
|
108
|
+
|
|
109
|
+
# 1. Size.
|
|
110
|
+
count += 1
|
|
111
|
+
is_str = issubclass(t, str)
|
|
112
|
+
if is_str:
|
|
113
|
+
n = str.__len__(v)
|
|
114
|
+
if n > ceiling - count:
|
|
115
|
+
# Counted only up to one past the ceiling: the verdict is the same.
|
|
116
|
+
count = ceiling + 1
|
|
117
|
+
return too_large()
|
|
118
|
+
count += n
|
|
119
|
+
if count > ceiling:
|
|
120
|
+
return too_large()
|
|
121
|
+
|
|
122
|
+
# 2. Type.
|
|
123
|
+
if v is None or t is bool:
|
|
124
|
+
setter(v)
|
|
125
|
+
continue
|
|
126
|
+
if is_str:
|
|
127
|
+
s = v if t is str else str.__str__(v)
|
|
128
|
+
admission_probe.strings_scanned += 1
|
|
129
|
+
if _text.has_surrogate_pair(s):
|
|
130
|
+
return invalid(path)
|
|
131
|
+
setter(s)
|
|
132
|
+
continue
|
|
133
|
+
if issubclass(t, int):
|
|
134
|
+
i = v if t is int else int.__int__(v)
|
|
135
|
+
if i > _MAX_SAFE or i < -_MAX_SAFE:
|
|
136
|
+
return invalid(path)
|
|
137
|
+
setter(i)
|
|
138
|
+
continue
|
|
139
|
+
if issubclass(t, float):
|
|
140
|
+
f = v if t is float else float.__float__(v)
|
|
141
|
+
if not math.isfinite(f):
|
|
142
|
+
return invalid(path)
|
|
143
|
+
setter(f)
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
is_list = issubclass(t, list)
|
|
147
|
+
if is_list or issubclass(t, tuple):
|
|
148
|
+
# 3. Depth.
|
|
149
|
+
if depth > ADMISSION_DEPTH_CEILING:
|
|
150
|
+
return invalid(path)
|
|
151
|
+
# 4. Member count, on entry.
|
|
152
|
+
length = list.__len__(v) if is_list else tuple.__len__(v)
|
|
153
|
+
count += length
|
|
154
|
+
if count > ceiling:
|
|
155
|
+
return too_large()
|
|
156
|
+
members = list.copy(v) if is_list else list(tuple.__iter__(v))
|
|
157
|
+
copy: list[Any] = [None] * len(members)
|
|
158
|
+
setter(copy)
|
|
159
|
+
for i in range(len(members) - 1, -1, -1):
|
|
160
|
+
stack.append((_VALUE, members[i], _text.child_path(path, i), depth + 1, _list_setter(copy, i)))
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
if issubclass(t, dict):
|
|
164
|
+
if depth > ADMISSION_DEPTH_CEILING:
|
|
165
|
+
return invalid(path)
|
|
166
|
+
length = dict.__len__(v)
|
|
167
|
+
count += length
|
|
168
|
+
if count > ceiling:
|
|
169
|
+
return too_large()
|
|
170
|
+
pairs = [(_key_name(k), m) for k, m in dict.items(v)]
|
|
171
|
+
ordered = _text._es_key_order(pairs, name=lambda p: p[0])
|
|
172
|
+
obj: dict[str, Any] = {}
|
|
173
|
+
setter(obj)
|
|
174
|
+
for i in range(len(ordered) - 1, -1, -1):
|
|
175
|
+
k, m = ordered[i]
|
|
176
|
+
member_path = path if k is _NOT_A_STR else _text.child_path(path, k)
|
|
177
|
+
stack.append((_VALUE, m, member_path, depth + 1, _dict_setter(obj, k)))
|
|
178
|
+
stack.append((_KEY, k, path))
|
|
179
|
+
continue
|
|
180
|
+
|
|
181
|
+
return invalid(path)
|
|
182
|
+
|
|
183
|
+
admission_probe.last_count = count
|
|
184
|
+
return Admitted(holder[0], count)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class _NotAStr:
|
|
188
|
+
__slots__ = ()
|
|
189
|
+
|
|
190
|
+
def __repr__(self) -> str:
|
|
191
|
+
return "<not a str>"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
_NOT_A_STR: Any = _NotAStr()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _key_name(k: Any) -> Any:
|
|
198
|
+
t = type(k)
|
|
199
|
+
if t is str:
|
|
200
|
+
return k
|
|
201
|
+
if issubclass(t, str):
|
|
202
|
+
return str.__str__(k)
|
|
203
|
+
return _NOT_A_STR
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _list_setter(copy: list[Any], i: int) -> Any:
|
|
207
|
+
def set_item(x: Any) -> None:
|
|
208
|
+
copy[i] = x
|
|
209
|
+
|
|
210
|
+
return set_item
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _dict_setter(obj: dict[str, Any], k: Any) -> Any:
|
|
214
|
+
def set_member(x: Any) -> None:
|
|
215
|
+
if k is not _NOT_A_STR:
|
|
216
|
+
obj[k] = x
|
|
217
|
+
|
|
218
|
+
return set_member
|