genieos 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.
- genieos-0.1.0/.gitignore +11 -0
- genieos-0.1.0/CHANGELOG.md +12 -0
- genieos-0.1.0/LICENSE +21 -0
- genieos-0.1.0/PKG-INFO +146 -0
- genieos-0.1.0/README.md +107 -0
- genieos-0.1.0/pyproject.toml +72 -0
- genieos-0.1.0/src/genieos/__init__.py +56 -0
- genieos-0.1.0/src/genieos/_errors.py +124 -0
- genieos-0.1.0/src/genieos/_telemetry.py +82 -0
- genieos-0.1.0/src/genieos/_transport.py +364 -0
- genieos-0.1.0/src/genieos/_types.py +283 -0
- genieos-0.1.0/src/genieos/async_client.py +399 -0
- genieos-0.1.0/src/genieos/client.py +397 -0
- genieos-0.1.0/src/genieos/py.typed +0 -0
- genieos-0.1.0/src/genieos/webhooks.py +191 -0
genieos-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# genieos (Python SDK)
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
Initial release.
|
|
6
|
+
|
|
7
|
+
- `GenieOS` (sync) and `AsyncGenieOS` (async) clients on top of `httpx`.
|
|
8
|
+
- Pydantic v2 models for typed responses.
|
|
9
|
+
- Auto idempotency keys; retry-on-429/5xx with exponential back-off.
|
|
10
|
+
- Resources: `workspace`, `templates`, `events`, `webhooks`, `audit`, `keys`.
|
|
11
|
+
- Webhook signature helpers: `verify_webhook`, `sign_webhook`, `WebhookSignatureError`.
|
|
12
|
+
- Typed errors inheriting from `GenieOSError`: auth / not-found / validation / conflict / rate-limit / server / network.
|
genieos-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MailGenius
|
|
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.
|
genieos-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: genieos
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for GenieOS — sync + async clients, typed responses, idempotency-aware retries, webhook verification.
|
|
5
|
+
Project-URL: Homepage, https://docs.genieos.pro/sdks/python
|
|
6
|
+
Project-URL: Documentation, https://docs.genieos.pro/sdks/python
|
|
7
|
+
Project-URL: Repository, https://github.com/GenieOS-0/sdk-python
|
|
8
|
+
Project-URL: Issues, https://github.com/GenieOS-0/sdk-python/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/GenieOS-0/sdk-python/blob/main/CHANGELOG.md
|
|
10
|
+
Author-email: GenieOS <developers@genieos.pro>
|
|
11
|
+
License: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: drip,email,genieos,mcp,sequences,transactional
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Topic :: Communications :: Email
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.9
|
|
26
|
+
Requires-Dist: httpx>=0.27
|
|
27
|
+
Requires-Dist: pydantic>=2.6
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
30
|
+
Requires-Dist: posthog>=3.0.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
33
|
+
Requires-Dist: python-dotenv>=1.0.0; extra == 'dev'
|
|
34
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
35
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
36
|
+
Provides-Extra: telemetry
|
|
37
|
+
Requires-Dist: posthog>=3.0.0; extra == 'telemetry'
|
|
38
|
+
Description-Content-Type: text/markdown
|
|
39
|
+
|
|
40
|
+
# genieos — Python SDK
|
|
41
|
+
|
|
42
|
+
[](https://pypi.org/project/genieos)
|
|
43
|
+
|
|
44
|
+
The official Python SDK for [GenieOS](https://genieos.pro). Sync and
|
|
45
|
+
async clients, typed responses (Pydantic), automatic idempotency,
|
|
46
|
+
retry-on-429/5xx with exponential back-off, and webhook signature
|
|
47
|
+
verification.
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install genieos
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Requires Python 3.9+ and `httpx>=0.27`, `pydantic>=2.6`.
|
|
56
|
+
|
|
57
|
+
## Quickstart (sync)
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from genieos import GenieOS
|
|
61
|
+
|
|
62
|
+
with GenieOS(api_key="gos_live_...") as mg:
|
|
63
|
+
ws = mg.workspace.get()
|
|
64
|
+
print(ws.name, "on", ws.plan)
|
|
65
|
+
|
|
66
|
+
send = mg.templates.send(
|
|
67
|
+
"welcome",
|
|
68
|
+
to="aki@example.com",
|
|
69
|
+
variables={"firstName": "Aki"},
|
|
70
|
+
)
|
|
71
|
+
print("queued:", send.id)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The API key is also picked up from the `GENIEOS_API_KEY` env var,
|
|
75
|
+
matching the Node SDK and CLI.
|
|
76
|
+
|
|
77
|
+
## Quickstart (async)
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
import asyncio
|
|
81
|
+
from genieos import AsyncGenieOS
|
|
82
|
+
|
|
83
|
+
async def main():
|
|
84
|
+
async with AsyncGenieOS() as mg: # MAILGENIUS_API_KEY env var
|
|
85
|
+
await mg.events.emit(
|
|
86
|
+
"subscription.cancelled",
|
|
87
|
+
email="aki@example.com",
|
|
88
|
+
traits={"tier": "pro", "reason": "moving to weekly"},
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
asyncio.run(main())
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Webhook verification (Flask)
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from flask import Flask, request, abort
|
|
98
|
+
from genieos import verify_webhook, WebhookSignatureError
|
|
99
|
+
|
|
100
|
+
app = Flask(__name__)
|
|
101
|
+
|
|
102
|
+
@app.post("/genieos/webhook")
|
|
103
|
+
def webhook():
|
|
104
|
+
try:
|
|
105
|
+
delivery = verify_webhook(
|
|
106
|
+
request.get_data(as_text=True),
|
|
107
|
+
request.headers,
|
|
108
|
+
secret=os.environ["MAILGENIUS_WEBHOOK_SECRET"],
|
|
109
|
+
)
|
|
110
|
+
except WebhookSignatureError as e:
|
|
111
|
+
abort(400, str(e))
|
|
112
|
+
|
|
113
|
+
if delivery.type == "send.delivered":
|
|
114
|
+
... # handle it
|
|
115
|
+
return "", 204
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`verify_webhook` rejects out-of-window timestamps (default ±5 min) and
|
|
119
|
+
performs constant-time signature comparison. The same module exposes
|
|
120
|
+
`sign_webhook` for local testing.
|
|
121
|
+
|
|
122
|
+
## Error handling
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
from genieos import (
|
|
126
|
+
GenieOSRateLimitError,
|
|
127
|
+
GenieOSValidationError,
|
|
128
|
+
GenieOSAuthError,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
mg.events.emit("checkout.completed", email="aki@example.com")
|
|
133
|
+
except GenieOSRateLimitError as e:
|
|
134
|
+
time.sleep(e.retry_after_seconds)
|
|
135
|
+
except GenieOSValidationError as e:
|
|
136
|
+
log.warning("422: %s", e.body)
|
|
137
|
+
except GenieOSAuthError:
|
|
138
|
+
rotate_my_key()
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
All SDK errors inherit from `GenieOSError` and expose `.code`,
|
|
142
|
+
`.status`, `.request_id`, `.body`.
|
|
143
|
+
|
|
144
|
+
## License
|
|
145
|
+
|
|
146
|
+
MIT
|
genieos-0.1.0/README.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# genieos — Python SDK
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/genieos)
|
|
4
|
+
|
|
5
|
+
The official Python SDK for [GenieOS](https://genieos.pro). Sync and
|
|
6
|
+
async clients, typed responses (Pydantic), automatic idempotency,
|
|
7
|
+
retry-on-429/5xx with exponential back-off, and webhook signature
|
|
8
|
+
verification.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install genieos
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Requires Python 3.9+ and `httpx>=0.27`, `pydantic>=2.6`.
|
|
17
|
+
|
|
18
|
+
## Quickstart (sync)
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from genieos import GenieOS
|
|
22
|
+
|
|
23
|
+
with GenieOS(api_key="gos_live_...") as mg:
|
|
24
|
+
ws = mg.workspace.get()
|
|
25
|
+
print(ws.name, "on", ws.plan)
|
|
26
|
+
|
|
27
|
+
send = mg.templates.send(
|
|
28
|
+
"welcome",
|
|
29
|
+
to="aki@example.com",
|
|
30
|
+
variables={"firstName": "Aki"},
|
|
31
|
+
)
|
|
32
|
+
print("queued:", send.id)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The API key is also picked up from the `GENIEOS_API_KEY` env var,
|
|
36
|
+
matching the Node SDK and CLI.
|
|
37
|
+
|
|
38
|
+
## Quickstart (async)
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
import asyncio
|
|
42
|
+
from genieos import AsyncGenieOS
|
|
43
|
+
|
|
44
|
+
async def main():
|
|
45
|
+
async with AsyncGenieOS() as mg: # MAILGENIUS_API_KEY env var
|
|
46
|
+
await mg.events.emit(
|
|
47
|
+
"subscription.cancelled",
|
|
48
|
+
email="aki@example.com",
|
|
49
|
+
traits={"tier": "pro", "reason": "moving to weekly"},
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
asyncio.run(main())
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Webhook verification (Flask)
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from flask import Flask, request, abort
|
|
59
|
+
from genieos import verify_webhook, WebhookSignatureError
|
|
60
|
+
|
|
61
|
+
app = Flask(__name__)
|
|
62
|
+
|
|
63
|
+
@app.post("/genieos/webhook")
|
|
64
|
+
def webhook():
|
|
65
|
+
try:
|
|
66
|
+
delivery = verify_webhook(
|
|
67
|
+
request.get_data(as_text=True),
|
|
68
|
+
request.headers,
|
|
69
|
+
secret=os.environ["MAILGENIUS_WEBHOOK_SECRET"],
|
|
70
|
+
)
|
|
71
|
+
except WebhookSignatureError as e:
|
|
72
|
+
abort(400, str(e))
|
|
73
|
+
|
|
74
|
+
if delivery.type == "send.delivered":
|
|
75
|
+
... # handle it
|
|
76
|
+
return "", 204
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`verify_webhook` rejects out-of-window timestamps (default ±5 min) and
|
|
80
|
+
performs constant-time signature comparison. The same module exposes
|
|
81
|
+
`sign_webhook` for local testing.
|
|
82
|
+
|
|
83
|
+
## Error handling
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from genieos import (
|
|
87
|
+
GenieOSRateLimitError,
|
|
88
|
+
GenieOSValidationError,
|
|
89
|
+
GenieOSAuthError,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
mg.events.emit("checkout.completed", email="aki@example.com")
|
|
94
|
+
except GenieOSRateLimitError as e:
|
|
95
|
+
time.sleep(e.retry_after_seconds)
|
|
96
|
+
except GenieOSValidationError as e:
|
|
97
|
+
log.warning("422: %s", e.body)
|
|
98
|
+
except GenieOSAuthError:
|
|
99
|
+
rotate_my_key()
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
All SDK errors inherit from `GenieOSError` and expose `.code`,
|
|
103
|
+
`.status`, `.request_id`, `.body`.
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
MIT
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.21"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "genieos"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python SDK for GenieOS — sync + async clients, typed responses, idempotency-aware retries, webhook verification."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [{ name = "GenieOS", email = "developers@genieos.pro" }]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
keywords = ["genieos", "email", "transactional", "drip", "sequences", "mcp"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.9",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Topic :: Communications :: Email",
|
|
24
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
25
|
+
"Typing :: Typed",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"httpx>=0.27",
|
|
29
|
+
"pydantic>=2.6",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://docs.genieos.pro/sdks/python"
|
|
34
|
+
Documentation = "https://docs.genieos.pro/sdks/python"
|
|
35
|
+
Repository = "https://github.com/GenieOS-0/sdk-python"
|
|
36
|
+
Issues = "https://github.com/GenieOS-0/sdk-python/issues"
|
|
37
|
+
Changelog = "https://github.com/GenieOS-0/sdk-python/blob/main/CHANGELOG.md"
|
|
38
|
+
|
|
39
|
+
[project.optional-dependencies]
|
|
40
|
+
telemetry = [
|
|
41
|
+
"posthog>=3.0.0",
|
|
42
|
+
]
|
|
43
|
+
dev = [
|
|
44
|
+
"pytest>=8.0",
|
|
45
|
+
"pytest-asyncio>=0.24",
|
|
46
|
+
"respx>=0.21",
|
|
47
|
+
"mypy>=1.10",
|
|
48
|
+
"ruff>=0.6",
|
|
49
|
+
"posthog>=3.0.0",
|
|
50
|
+
"python-dotenv>=1.0.0",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
[tool.hatch.build.targets.wheel]
|
|
54
|
+
packages = ["src/genieos"]
|
|
55
|
+
|
|
56
|
+
[tool.hatch.build.targets.sdist]
|
|
57
|
+
include = ["src/genieos", "README.md", "LICENSE", "CHANGELOG.md", "pyproject.toml"]
|
|
58
|
+
|
|
59
|
+
[tool.pytest.ini_options]
|
|
60
|
+
asyncio_mode = "auto"
|
|
61
|
+
testpaths = ["tests"]
|
|
62
|
+
|
|
63
|
+
[tool.ruff]
|
|
64
|
+
target-version = "py39"
|
|
65
|
+
line-length = 100
|
|
66
|
+
|
|
67
|
+
[tool.ruff.lint]
|
|
68
|
+
select = ["E", "F", "I", "N", "B", "UP", "ASYNC"]
|
|
69
|
+
|
|
70
|
+
[tool.mypy]
|
|
71
|
+
strict = true
|
|
72
|
+
python_version = "3.9"
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GenieOS — official Python SDK.
|
|
3
|
+
|
|
4
|
+
Quickstart::
|
|
5
|
+
|
|
6
|
+
from genieos import GenieOS
|
|
7
|
+
|
|
8
|
+
with GenieOS(api_key="gos_live_...") as mg:
|
|
9
|
+
ws = mg.workspace.get()
|
|
10
|
+
print(ws.name, ws.plan)
|
|
11
|
+
|
|
12
|
+
send = mg.templates.send(
|
|
13
|
+
"welcome",
|
|
14
|
+
to="aki@example.com",
|
|
15
|
+
variables={"firstName": "Aki"},
|
|
16
|
+
)
|
|
17
|
+
print("send id:", send.id)
|
|
18
|
+
|
|
19
|
+
For asynchronous code, use ``AsyncGenieOS`` which exposes the same
|
|
20
|
+
resource surface with awaitable methods.
|
|
21
|
+
"""
|
|
22
|
+
from ._errors import (
|
|
23
|
+
GenieOSAuthError,
|
|
24
|
+
GenieOSConflictError,
|
|
25
|
+
GenieOSError,
|
|
26
|
+
GenieOSNetworkError,
|
|
27
|
+
GenieOSNotFoundError,
|
|
28
|
+
GenieOSRateLimitError,
|
|
29
|
+
GenieOSServerError,
|
|
30
|
+
GenieOSValidationError,
|
|
31
|
+
)
|
|
32
|
+
from ._transport import DEFAULT_BASE_URL
|
|
33
|
+
from .async_client import AsyncGenieOS
|
|
34
|
+
from .client import GenieOS
|
|
35
|
+
from .webhooks import VerifiedDelivery, WebhookSignatureError, sign_webhook, verify_webhook
|
|
36
|
+
|
|
37
|
+
__version__ = "0.1.0"
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"__version__",
|
|
41
|
+
"DEFAULT_BASE_URL",
|
|
42
|
+
"GenieOS",
|
|
43
|
+
"AsyncGenieOS",
|
|
44
|
+
"GenieOSError",
|
|
45
|
+
"GenieOSAuthError",
|
|
46
|
+
"GenieOSNotFoundError",
|
|
47
|
+
"GenieOSValidationError",
|
|
48
|
+
"GenieOSConflictError",
|
|
49
|
+
"GenieOSRateLimitError",
|
|
50
|
+
"GenieOSServerError",
|
|
51
|
+
"GenieOSNetworkError",
|
|
52
|
+
"VerifiedDelivery",
|
|
53
|
+
"WebhookSignatureError",
|
|
54
|
+
"verify_webhook",
|
|
55
|
+
"sign_webhook",
|
|
56
|
+
]
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Typed error hierarchy for the GenieOS SDK — mirrors the catalogue
|
|
3
|
+
in ``packages/sdk-node/src/errors.ts`` so the same docs cover both
|
|
4
|
+
SDKs.
|
|
5
|
+
|
|
6
|
+
The base class is :class:`GenieOSError`. Every subclass corresponds
|
|
7
|
+
to a documented API error code so callers can
|
|
8
|
+
``except GenieOSRateLimitError`` instead of matching on string codes.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class GenieOSError(Exception):
|
|
16
|
+
"""Base class for every error raised by the GenieOS SDK."""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
message: str,
|
|
21
|
+
*,
|
|
22
|
+
code: str = "genieos_error",
|
|
23
|
+
status: Optional[int] = None,
|
|
24
|
+
request_id: Optional[str] = None,
|
|
25
|
+
body: Optional[Any] = None,
|
|
26
|
+
) -> None:
|
|
27
|
+
super().__init__(message)
|
|
28
|
+
self.code = code
|
|
29
|
+
self.status = status
|
|
30
|
+
self.request_id = request_id
|
|
31
|
+
self.body = body
|
|
32
|
+
|
|
33
|
+
def __repr__(self) -> str: # pragma: no cover - cosmetic
|
|
34
|
+
parts = [f"{self.__class__.__name__}({self.args[0]!r}", f"code={self.code!r}"]
|
|
35
|
+
if self.status is not None:
|
|
36
|
+
parts.append(f"status={self.status}")
|
|
37
|
+
if self.request_id is not None:
|
|
38
|
+
parts.append(f"request_id={self.request_id!r}")
|
|
39
|
+
return ", ".join(parts) + ")"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class GenieOSAuthError(GenieOSError):
|
|
43
|
+
"""401/403 — bearer token missing, invalid, revoked, or out-of-scope."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class GenieOSNotFoundError(GenieOSError):
|
|
47
|
+
"""404 — addressed resource does not exist (or is in another workspace)."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class GenieOSValidationError(GenieOSError):
|
|
51
|
+
"""422 — request body / query failed schema validation."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class GenieOSConflictError(GenieOSError):
|
|
55
|
+
"""409 — idempotency conflict, optimistic-concurrency mismatch, etc."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class GenieOSRateLimitError(GenieOSError):
|
|
59
|
+
"""429 — per-key or per-workspace rate limit exceeded.
|
|
60
|
+
|
|
61
|
+
``retry_after_seconds`` mirrors the ``Retry-After`` header on the
|
|
62
|
+
response and is the recommended back-off duration before the next
|
|
63
|
+
attempt.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
message: str,
|
|
69
|
+
*,
|
|
70
|
+
retry_after_seconds: float,
|
|
71
|
+
**kwargs: Any,
|
|
72
|
+
) -> None:
|
|
73
|
+
super().__init__(message, **kwargs)
|
|
74
|
+
self.retry_after_seconds = retry_after_seconds
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class GenieOSServerError(GenieOSError):
|
|
78
|
+
"""5xx — transient server-side fault. Retried automatically by the SDK."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class GenieOSNetworkError(GenieOSError):
|
|
82
|
+
"""Local network / DNS / TLS failure."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def from_response(
|
|
86
|
+
*,
|
|
87
|
+
status: int,
|
|
88
|
+
body: Any,
|
|
89
|
+
request_id: Optional[str],
|
|
90
|
+
retry_after_seconds: Optional[float] = None,
|
|
91
|
+
) -> GenieOSError:
|
|
92
|
+
"""Convert an HTTP error response into the appropriate typed error."""
|
|
93
|
+
err_obj = (body or {}).get("error", {}) if isinstance(body, dict) else {}
|
|
94
|
+
code = err_obj.get("code") or f"http_{status}"
|
|
95
|
+
message = err_obj.get("message") or f"HTTP {status}"
|
|
96
|
+
common = {"code": code, "status": status, "request_id": request_id, "body": body}
|
|
97
|
+
if status in (401, 403):
|
|
98
|
+
return GenieOSAuthError(message, **common)
|
|
99
|
+
if status == 404:
|
|
100
|
+
return GenieOSNotFoundError(message, **common)
|
|
101
|
+
if status == 409:
|
|
102
|
+
return GenieOSConflictError(message, **common)
|
|
103
|
+
if status == 422:
|
|
104
|
+
return GenieOSValidationError(message, **common)
|
|
105
|
+
if status == 429:
|
|
106
|
+
# `or` would coerce a legitimate 0 ("retry now") into 1s, so check None.
|
|
107
|
+
ra = retry_after_seconds if retry_after_seconds is not None else 1.0
|
|
108
|
+
return GenieOSRateLimitError(message, retry_after_seconds=ra, **common)
|
|
109
|
+
if status >= 500:
|
|
110
|
+
return GenieOSServerError(message, **common)
|
|
111
|
+
return GenieOSError(message, **common)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
__all__ = [
|
|
115
|
+
"GenieOSError",
|
|
116
|
+
"GenieOSAuthError",
|
|
117
|
+
"GenieOSNotFoundError",
|
|
118
|
+
"GenieOSValidationError",
|
|
119
|
+
"GenieOSConflictError",
|
|
120
|
+
"GenieOSRateLimitError",
|
|
121
|
+
"GenieOSServerError",
|
|
122
|
+
"GenieOSNetworkError",
|
|
123
|
+
"from_response",
|
|
124
|
+
]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Optional PostHog telemetry for the GenieOS Python SDK.
|
|
3
|
+
|
|
4
|
+
Telemetry is enabled only when POSTHOG_PROJECT_TOKEN is set. It tracks
|
|
5
|
+
SDK-usage signals (which operations are called, error rates by code,
|
|
6
|
+
webhook verification outcomes) to help the GenieOS team understand
|
|
7
|
+
how the SDK is used and where developers hit problems.
|
|
8
|
+
|
|
9
|
+
No PII is ever sent — the distinct_id is a SHA-256 hash of the API key
|
|
10
|
+
prefix, so it is workspace-scoped but cannot be reverse-engineered.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import os
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
_client: Any = None
|
|
19
|
+
_initialized = False
|
|
20
|
+
|
|
21
|
+
# Used for calls (e.g. webhook verification) where no API key is available.
|
|
22
|
+
ANONYMOUS_DISTINCT_ID = "sdk-py-webhook-verifier"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _distinct_id(api_key: str) -> str:
|
|
26
|
+
"""Return a non-reversible, workspace-scoped identifier."""
|
|
27
|
+
return "sdk-py-" + hashlib.sha256(api_key.encode()).hexdigest()[:16]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_client() -> Any | None:
|
|
31
|
+
"""Return the (lazily-initialized) PostHog client, or None if disabled."""
|
|
32
|
+
global _client, _initialized
|
|
33
|
+
if _initialized:
|
|
34
|
+
return _client
|
|
35
|
+
|
|
36
|
+
_initialized = True
|
|
37
|
+
token = os.environ.get("POSTHOG_PROJECT_TOKEN", "").strip()
|
|
38
|
+
if not token:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
from posthog import Posthog # type: ignore[import]
|
|
43
|
+
|
|
44
|
+
kwargs: dict[str, Any] = {"enable_exception_autocapture": True}
|
|
45
|
+
host = os.environ.get("POSTHOG_HOST", "").strip()
|
|
46
|
+
if host:
|
|
47
|
+
kwargs["host"] = host
|
|
48
|
+
_client = Posthog(token, **kwargs)
|
|
49
|
+
except Exception:
|
|
50
|
+
# Never let PostHog errors surface to SDK consumers.
|
|
51
|
+
_client = None
|
|
52
|
+
|
|
53
|
+
return _client
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def capture(
|
|
57
|
+
api_key: str,
|
|
58
|
+
event: str,
|
|
59
|
+
properties: dict[str, Any] | None = None,
|
|
60
|
+
*,
|
|
61
|
+
distinct_id: str | None = None,
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Fire a PostHog event; silently no-ops if telemetry is disabled.
|
|
64
|
+
|
|
65
|
+
If ``distinct_id`` is provided it is used directly; otherwise it is
|
|
66
|
+
derived from ``api_key`` via SHA-256 so no raw key is transmitted.
|
|
67
|
+
"""
|
|
68
|
+
client = get_client()
|
|
69
|
+
if client is None:
|
|
70
|
+
return
|
|
71
|
+
try:
|
|
72
|
+
did = distinct_id if distinct_id is not None else _distinct_id(api_key)
|
|
73
|
+
client.capture(
|
|
74
|
+
distinct_id=did,
|
|
75
|
+
event=event,
|
|
76
|
+
properties=properties or {},
|
|
77
|
+
)
|
|
78
|
+
except Exception:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
__all__ = ["capture", "get_client", "ANONYMOUS_DISTINCT_ID"]
|