apisim 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.
- apisim-0.1.0/.gitignore +24 -0
- apisim-0.1.0/PKG-INFO +79 -0
- apisim-0.1.0/README.md +62 -0
- apisim-0.1.0/pyproject.toml +33 -0
- apisim-0.1.0/src/apisim/__init__.py +15 -0
- apisim-0.1.0/src/apisim/client.py +236 -0
- apisim-0.1.0/src/apisim/py.typed +0 -0
- apisim-0.1.0/src/apisim/webhooks.py +43 -0
- apisim-0.1.0/tests/test_client.py +70 -0
- apisim-0.1.0/uv.lock +189 -0
apisim-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
.env
|
|
2
|
+
.env.*
|
|
3
|
+
!.env.example
|
|
4
|
+
.venv/
|
|
5
|
+
__pycache__/
|
|
6
|
+
.pytest_cache/
|
|
7
|
+
.ruff_cache/
|
|
8
|
+
node_modules/
|
|
9
|
+
.next/
|
|
10
|
+
.data/
|
|
11
|
+
artifacts/
|
|
12
|
+
*.log
|
|
13
|
+
.DS_Store
|
|
14
|
+
.gradle/
|
|
15
|
+
**/build/
|
|
16
|
+
local.properties
|
|
17
|
+
*.jks
|
|
18
|
+
*.tsbuildinfo
|
|
19
|
+
|
|
20
|
+
# Build output and local browser-test artifacts
|
|
21
|
+
mcp/dist/
|
|
22
|
+
.playwright-mcp/
|
|
23
|
+
sdk/typescript/dist/
|
|
24
|
+
sdk/python/dist/
|
apisim-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: apisim
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the ApiSIM API: send SMS, place calls and read conversations through a phone you own.
|
|
5
|
+
Project-URL: Homepage, https://apisim.app/docs
|
|
6
|
+
Project-URL: Repository, https://github.com/ronnybru/apisim
|
|
7
|
+
Project-URL: Issues, https://github.com/ronnybru/apisim/issues
|
|
8
|
+
Author-email: Digital Trend AS <support@apisim.app>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: ai-agents,api,apisim,phone,sms,voice,webhooks
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Communications :: Telephony
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Requires-Dist: httpx>=0.27
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# apisim
|
|
19
|
+
|
|
20
|
+
Python client for the [ApiSIM](https://apisim.app) API: send SMS, place calls with browser or AI
|
|
21
|
+
audio, read conversations and recordings, all through an Android phone and SIM you own.
|
|
22
|
+
Python 3.11+, one dependency (`httpx`).
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
pip install apisim
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
import os
|
|
30
|
+
from apisim import ApiSim
|
|
31
|
+
|
|
32
|
+
apisim = ApiSim(api_key=os.environ["APISIM_API_KEY"])
|
|
33
|
+
|
|
34
|
+
line = apisim.lines.list()[0]
|
|
35
|
+
op = apisim.messages.send(
|
|
36
|
+
line["id"],
|
|
37
|
+
"+15555550101",
|
|
38
|
+
"Hi Alex, can I help?",
|
|
39
|
+
idempotency_key="welcome-1042", # one real-world message; retries with the same key never send twice
|
|
40
|
+
)
|
|
41
|
+
print(apisim.operations.wait_for(op["id"])["state"]) # sent | delivered | failed | uncertain
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Outcomes are honest: `sent` is carrier acceptance, `delivered` is handset confirmation, and
|
|
45
|
+
`uncertain` means the phone went quiet mid-action; reconcile before retrying with a new key.
|
|
46
|
+
|
|
47
|
+
## Events and webhooks
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
for event in apisim.events.stream(after=last_seq):
|
|
51
|
+
if event["type"] == "messages.incoming":
|
|
52
|
+
handle(event["data"])
|
|
53
|
+
last_seq = event["seq"] # persist it
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Or register a webhook and verify each delivery against the raw request body:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from apisim import parse_webhook
|
|
60
|
+
|
|
61
|
+
event = parse_webhook(
|
|
62
|
+
os.environ["APISIM_WEBHOOK_SECRET"], request.headers.get("X-Apisim-Signature"), request.body
|
|
63
|
+
)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Delivery is at least once; deduplicate on the `X-Apisim-Event` header.
|
|
67
|
+
|
|
68
|
+
## Calls
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
call = apisim.calls.start(line["id"], "+15555550101", idempotency_key="callback-77")
|
|
72
|
+
session = apisim.calls.session(call["id"]) # {"url", "token", "room"}: WebRTC credentials for your agent
|
|
73
|
+
apisim.calls.end(call["id"])
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Calling is enabled per line after a conference-capability check; recording needs `record=True`,
|
|
77
|
+
`recording_notice_acknowledged=True` and credit.
|
|
78
|
+
|
|
79
|
+
Full reference: https://apisim.app/docs and the OpenAPI document at https://api.apisim.app/docs.
|
apisim-0.1.0/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# apisim
|
|
2
|
+
|
|
3
|
+
Python client for the [ApiSIM](https://apisim.app) API: send SMS, place calls with browser or AI
|
|
4
|
+
audio, read conversations and recordings, all through an Android phone and SIM you own.
|
|
5
|
+
Python 3.11+, one dependency (`httpx`).
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pip install apisim
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
import os
|
|
13
|
+
from apisim import ApiSim
|
|
14
|
+
|
|
15
|
+
apisim = ApiSim(api_key=os.environ["APISIM_API_KEY"])
|
|
16
|
+
|
|
17
|
+
line = apisim.lines.list()[0]
|
|
18
|
+
op = apisim.messages.send(
|
|
19
|
+
line["id"],
|
|
20
|
+
"+15555550101",
|
|
21
|
+
"Hi Alex, can I help?",
|
|
22
|
+
idempotency_key="welcome-1042", # one real-world message; retries with the same key never send twice
|
|
23
|
+
)
|
|
24
|
+
print(apisim.operations.wait_for(op["id"])["state"]) # sent | delivered | failed | uncertain
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Outcomes are honest: `sent` is carrier acceptance, `delivered` is handset confirmation, and
|
|
28
|
+
`uncertain` means the phone went quiet mid-action; reconcile before retrying with a new key.
|
|
29
|
+
|
|
30
|
+
## Events and webhooks
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
for event in apisim.events.stream(after=last_seq):
|
|
34
|
+
if event["type"] == "messages.incoming":
|
|
35
|
+
handle(event["data"])
|
|
36
|
+
last_seq = event["seq"] # persist it
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Or register a webhook and verify each delivery against the raw request body:
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from apisim import parse_webhook
|
|
43
|
+
|
|
44
|
+
event = parse_webhook(
|
|
45
|
+
os.environ["APISIM_WEBHOOK_SECRET"], request.headers.get("X-Apisim-Signature"), request.body
|
|
46
|
+
)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Delivery is at least once; deduplicate on the `X-Apisim-Event` header.
|
|
50
|
+
|
|
51
|
+
## Calls
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
call = apisim.calls.start(line["id"], "+15555550101", idempotency_key="callback-77")
|
|
55
|
+
session = apisim.calls.session(call["id"]) # {"url", "token", "room"}: WebRTC credentials for your agent
|
|
56
|
+
apisim.calls.end(call["id"])
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Calling is enabled per line after a conference-capability check; recording needs `record=True`,
|
|
60
|
+
`recording_notice_acknowledged=True` and credit.
|
|
61
|
+
|
|
62
|
+
Full reference: https://apisim.app/docs and the OpenAPI document at https://api.apisim.app/docs.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "apisim"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Python client for the ApiSIM API: send SMS, place calls and read conversations through a phone you own."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.11"
|
|
8
|
+
authors = [{ name = "Digital Trend AS", email = "support@apisim.app" }]
|
|
9
|
+
keywords = ["sms", "voice", "phone", "api", "ai-agents", "webhooks", "apisim"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Programming Language :: Python :: 3",
|
|
12
|
+
"Topic :: Communications :: Telephony",
|
|
13
|
+
"Typing :: Typed",
|
|
14
|
+
]
|
|
15
|
+
dependencies = ["httpx>=0.27"]
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Homepage = "https://apisim.app/docs"
|
|
19
|
+
Repository = "https://github.com/ronnybru/apisim"
|
|
20
|
+
Issues = "https://github.com/ronnybru/apisim/issues"
|
|
21
|
+
|
|
22
|
+
[dependency-groups]
|
|
23
|
+
dev = ["pytest>=8", "ruff>=0.13"]
|
|
24
|
+
|
|
25
|
+
[build-system]
|
|
26
|
+
requires = ["hatchling"]
|
|
27
|
+
build-backend = "hatchling.build"
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.wheel]
|
|
30
|
+
packages = ["src/apisim"]
|
|
31
|
+
|
|
32
|
+
[tool.ruff]
|
|
33
|
+
line-length = 110
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""ApiSIM Python SDK: your business phone, connected to software.
|
|
2
|
+
|
|
3
|
+
from apisim import ApiSim
|
|
4
|
+
|
|
5
|
+
apisim = ApiSim(api_key=os.environ["APISIM_API_KEY"])
|
|
6
|
+
line = apisim.lines.list()[0]
|
|
7
|
+
op = apisim.messages.send(line["id"], "+15555550101", "Hi Alex, can I help?", idempotency_key="welcome-1042")
|
|
8
|
+
print(apisim.operations.wait_for(op["id"])["state"]) # sent | delivered | failed | uncertain
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .client import ApiSim, ApiSimError
|
|
12
|
+
from .webhooks import parse_webhook, verify_webhook
|
|
13
|
+
|
|
14
|
+
__all__ = ["ApiSim", "ApiSimError", "parse_webhook", "verify_webhook"]
|
|
15
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Synchronous client over httpx. Every write takes an idempotency key: one key, one real action."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from typing import Any, Self
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
TERMINAL = ("sent", "delivered", "failed", "uncertain", "completed")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ApiSimError(Exception):
|
|
15
|
+
"""An error answer from the API: `status`, the API's `detail`, and `retry_after` seconds on 429."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, status: int, detail: Any, retry_after: float | None = None):
|
|
18
|
+
super().__init__(detail if isinstance(detail, str) else repr(detail))
|
|
19
|
+
self.status = status
|
|
20
|
+
self.detail = detail
|
|
21
|
+
self.retry_after = retry_after
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ApiSim:
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
api_key: str,
|
|
28
|
+
base_url: str = "https://api.apisim.app",
|
|
29
|
+
timeout: float = 30.0,
|
|
30
|
+
transport: httpx.BaseTransport | None = None,
|
|
31
|
+
):
|
|
32
|
+
if not api_key:
|
|
33
|
+
raise ValueError("api_key is required (create one in the dashboard)")
|
|
34
|
+
self._http = httpx.Client(
|
|
35
|
+
base_url=base_url.rstrip("/"),
|
|
36
|
+
timeout=timeout,
|
|
37
|
+
transport=transport,
|
|
38
|
+
headers={"Authorization": f"Bearer {api_key}", "User-Agent": "apisim-sdk-python/0.1"},
|
|
39
|
+
)
|
|
40
|
+
self.lines = _Lines(self)
|
|
41
|
+
self.devices = _Devices(self)
|
|
42
|
+
self.messages = _Messages(self)
|
|
43
|
+
self.operations = _Operations(self)
|
|
44
|
+
self.calls = _Calls(self)
|
|
45
|
+
self.recordings = _Recordings(self)
|
|
46
|
+
self.events = _Events(self)
|
|
47
|
+
self.webhooks = _Webhooks(self)
|
|
48
|
+
self.keys = _Keys(self)
|
|
49
|
+
self.billing = _Billing(self)
|
|
50
|
+
|
|
51
|
+
def close(self) -> None:
|
|
52
|
+
self._http.close()
|
|
53
|
+
|
|
54
|
+
def __enter__(self) -> Self:
|
|
55
|
+
return self
|
|
56
|
+
|
|
57
|
+
def __exit__(self, *exc: object) -> None:
|
|
58
|
+
self.close()
|
|
59
|
+
|
|
60
|
+
def request(
|
|
61
|
+
self,
|
|
62
|
+
method: str,
|
|
63
|
+
path: str,
|
|
64
|
+
json: Any = None,
|
|
65
|
+
params: dict[str, Any] | None = None,
|
|
66
|
+
idempotency_key: str | None = None,
|
|
67
|
+
) -> Any:
|
|
68
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else {}
|
|
69
|
+
response = self._http.request(method, path, json=json, params=params, headers=headers)
|
|
70
|
+
try:
|
|
71
|
+
data = response.json() if response.content else {}
|
|
72
|
+
except ValueError:
|
|
73
|
+
data = response.text
|
|
74
|
+
if response.is_error:
|
|
75
|
+
detail = data.get("detail", data) if isinstance(data, dict) else data
|
|
76
|
+
retry = response.headers.get("retry-after")
|
|
77
|
+
raise ApiSimError(
|
|
78
|
+
response.status_code, detail or response.reason_phrase, float(retry) if retry else None
|
|
79
|
+
)
|
|
80
|
+
return data
|
|
81
|
+
|
|
82
|
+
def me(self) -> dict[str, Any]:
|
|
83
|
+
"""Workspace identity, e-mail verification and the capabilities this deployment enables."""
|
|
84
|
+
return self.request("GET", "/v1/me")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class _Resource:
|
|
88
|
+
def __init__(self, client: ApiSim):
|
|
89
|
+
self._c = client
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class _Lines(_Resource):
|
|
93
|
+
def list(self) -> list[dict[str, Any]]:
|
|
94
|
+
return self._c.request("GET", "/v1/lines")
|
|
95
|
+
|
|
96
|
+
def set_calling(self, line_id: str, enabled: bool, conference_confirmed: bool = False) -> dict[str, Any]:
|
|
97
|
+
"""Enable calling after confirming the carrier's conference calling works on this SIM."""
|
|
98
|
+
body = {"enabled": enabled, "conference_confirmed": conference_confirmed}
|
|
99
|
+
return self._c.request("POST", f"/v1/lines/{line_id}/calling", json=body)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class _Devices(_Resource):
|
|
103
|
+
def list(self) -> list[dict[str, Any]]:
|
|
104
|
+
return self._c.request("GET", "/v1/devices")
|
|
105
|
+
|
|
106
|
+
def disconnect(self, device_id: str) -> Any:
|
|
107
|
+
return self._c.request("DELETE", f"/v1/devices/{device_id}")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class _Messages(_Resource):
|
|
111
|
+
def send(self, line_id: str, to: str, body: str, idempotency_key: str) -> dict[str, Any]:
|
|
112
|
+
"""Queue an SMS on a line; `idempotency_key` identifies this one message and makes retries safe."""
|
|
113
|
+
payload = {"line_id": line_id, "to": to, "body": body}
|
|
114
|
+
return self._c.request("POST", "/v1/messages", json=payload, idempotency_key=idempotency_key)
|
|
115
|
+
|
|
116
|
+
def list(self) -> list[dict[str, Any]]:
|
|
117
|
+
"""Scoped business conversations: replies and manual messages to numbers you have contacted."""
|
|
118
|
+
return self._c.request("GET", "/v1/messages")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class _Operations(_Resource):
|
|
122
|
+
def list(self) -> list[dict[str, Any]]:
|
|
123
|
+
return self._c.request("GET", "/v1/operations")
|
|
124
|
+
|
|
125
|
+
def get(self, operation_id: str) -> dict[str, Any]:
|
|
126
|
+
return self._c.request("GET", f"/v1/operations/{operation_id}")
|
|
127
|
+
|
|
128
|
+
def wait_for(
|
|
129
|
+
self,
|
|
130
|
+
operation_id: str,
|
|
131
|
+
until: tuple[str, ...] = TERMINAL,
|
|
132
|
+
timeout: float = 60.0,
|
|
133
|
+
interval: float = 1.5,
|
|
134
|
+
) -> dict[str, Any]:
|
|
135
|
+
"""Poll until the operation reaches one of `until`; raises ApiSimError(408) on timeout."""
|
|
136
|
+
deadline = time.monotonic() + timeout
|
|
137
|
+
while True:
|
|
138
|
+
op = self.get(operation_id)
|
|
139
|
+
if op["state"] in until:
|
|
140
|
+
return op
|
|
141
|
+
if time.monotonic() >= deadline:
|
|
142
|
+
raise ApiSimError(408, f"operation {operation_id} still {op['state']} after timeout")
|
|
143
|
+
time.sleep(interval)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class _Calls(_Resource):
|
|
147
|
+
def start(
|
|
148
|
+
self,
|
|
149
|
+
line_id: str,
|
|
150
|
+
to: str,
|
|
151
|
+
idempotency_key: str,
|
|
152
|
+
record: bool = False,
|
|
153
|
+
recording_notice_acknowledged: bool = False,
|
|
154
|
+
) -> dict[str, Any]:
|
|
155
|
+
"""Dial from the line through the bridge; the recipient sees the SIM's number."""
|
|
156
|
+
payload = {
|
|
157
|
+
"line_id": line_id,
|
|
158
|
+
"to": to,
|
|
159
|
+
"record": record,
|
|
160
|
+
"recording_notice_acknowledged": recording_notice_acknowledged,
|
|
161
|
+
}
|
|
162
|
+
return self._c.request("POST", "/v1/calls", json=payload, idempotency_key=idempotency_key)
|
|
163
|
+
|
|
164
|
+
def answer(self, call_id: str, record: bool = False, recording_notice_acknowledged: bool = False) -> dict:
|
|
165
|
+
body = {"record": record, "recording_notice_acknowledged": recording_notice_acknowledged}
|
|
166
|
+
return self._c.request("POST", f"/v1/calls/{call_id}/answer", json=body)
|
|
167
|
+
|
|
168
|
+
def end(self, call_id: str) -> dict[str, Any]:
|
|
169
|
+
return self._c.request("POST", f"/v1/calls/{call_id}/end", json={})
|
|
170
|
+
|
|
171
|
+
def session(self, call_id: str) -> dict[str, Any]:
|
|
172
|
+
"""Short-lived WebRTC credentials (`url`, `token`, `room`) for an agent to join the call."""
|
|
173
|
+
return self._c.request("POST", f"/v1/calls/{call_id}/session", json={})
|
|
174
|
+
|
|
175
|
+
def start_recording(self, call_id: str) -> dict[str, Any]:
|
|
176
|
+
"""Record at the bridge; needs record + notice acknowledgement on the call, and credit."""
|
|
177
|
+
return self._c.request("POST", f"/v1/calls/{call_id}/recording", json={})
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class _Recordings(_Resource):
|
|
181
|
+
def list(self) -> list[dict[str, Any]]:
|
|
182
|
+
return self._c.request("GET", "/v1/recordings")
|
|
183
|
+
|
|
184
|
+
def playback(self, recording_id: str) -> dict[str, Any]:
|
|
185
|
+
"""Two-minute private URL (`url`, `expires_in`)."""
|
|
186
|
+
return self._c.request("GET", f"/v1/recordings/{recording_id}/playback")
|
|
187
|
+
|
|
188
|
+
def delete(self, recording_id: str) -> Any:
|
|
189
|
+
return self._c.request("DELETE", f"/v1/recordings/{recording_id}")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class _Events(_Resource):
|
|
193
|
+
def list(self, after: int = 0) -> list[dict[str, Any]]:
|
|
194
|
+
"""Up to 200 events with seq greater than `after`, in order. Persist the last seq you processed."""
|
|
195
|
+
return self._c.request("GET", "/v1/events", params={"after": after})
|
|
196
|
+
|
|
197
|
+
def stream(self, after: int = 0, interval: float = 2.0) -> Iterator[dict[str, Any]]:
|
|
198
|
+
"""Yield events as they appear, resuming from `after`. Runs until the caller stops iterating."""
|
|
199
|
+
while True:
|
|
200
|
+
batch = self.list(after)
|
|
201
|
+
for event in batch:
|
|
202
|
+
after = event["seq"]
|
|
203
|
+
yield event
|
|
204
|
+
if not batch:
|
|
205
|
+
time.sleep(interval)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class _Webhooks(_Resource):
|
|
209
|
+
def create(self, url: str) -> dict[str, Any]:
|
|
210
|
+
"""The signing `secret` is returned once; keep it for verify_webhook()."""
|
|
211
|
+
return self._c.request("POST", "/v1/webhooks", json={"url": url})
|
|
212
|
+
|
|
213
|
+
def list(self) -> list[dict[str, Any]]:
|
|
214
|
+
return self._c.request("GET", "/v1/webhooks")
|
|
215
|
+
|
|
216
|
+
def delete(self, webhook_id: str) -> Any:
|
|
217
|
+
return self._c.request("DELETE", f"/v1/webhooks/{webhook_id}")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class _Keys(_Resource):
|
|
221
|
+
def list(self) -> list[dict[str, Any]]:
|
|
222
|
+
return self._c.request("GET", "/v1/keys")
|
|
223
|
+
|
|
224
|
+
def create(self, label: str) -> dict[str, Any]:
|
|
225
|
+
return self._c.request("POST", "/v1/keys", json={"label": label})
|
|
226
|
+
|
|
227
|
+
def revoke(self, key_id: str) -> Any:
|
|
228
|
+
return self._c.request("DELETE", f"/v1/keys/{key_id}")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class _Billing(_Resource):
|
|
232
|
+
def status(self) -> dict[str, Any]:
|
|
233
|
+
return self._c.request("GET", "/v1/billing")
|
|
234
|
+
|
|
235
|
+
def ledger(self) -> list[dict[str, Any]]:
|
|
236
|
+
return self._c.request("GET", "/v1/billing/ledger")
|
|
File without changes
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Verify ApiSIM webhook deliveries.
|
|
2
|
+
|
|
3
|
+
Header `X-Apisim-Signature: t=<unix seconds>,v1=<hex>` where
|
|
4
|
+
v1 = HMAC-SHA256(secret, f"{t}.{raw_body}"). Always verify against the raw request body as received,
|
|
5
|
+
never re-serialised JSON. Delivery is at least once: deduplicate on the `X-Apisim-Event` header.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import hmac
|
|
12
|
+
import json
|
|
13
|
+
import time
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .client import ApiSimError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def verify_webhook(
|
|
20
|
+
secret: str,
|
|
21
|
+
signature: str | None,
|
|
22
|
+
raw_body: bytes | str,
|
|
23
|
+
tolerance_seconds: int = 300,
|
|
24
|
+
now: float | None = None,
|
|
25
|
+
) -> bool:
|
|
26
|
+
parts = dict(p.split("=", 1) for p in (signature or "").split(",") if "=" in p)
|
|
27
|
+
timestamp, given = parts.get("t"), parts.get("v1")
|
|
28
|
+
if not timestamp or not given or not timestamp.isdigit():
|
|
29
|
+
return False
|
|
30
|
+
if abs((now if now is not None else time.time()) - int(timestamp)) > tolerance_seconds:
|
|
31
|
+
return False
|
|
32
|
+
body = raw_body.encode() if isinstance(raw_body, str) else raw_body
|
|
33
|
+
expected = hmac.new(secret.encode(), timestamp.encode() + b"." + body, hashlib.sha256).hexdigest()
|
|
34
|
+
return hmac.compare_digest(expected, given.lower())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_webhook(
|
|
38
|
+
secret: str, signature: str | None, raw_body: bytes | str, tolerance_seconds: int = 300
|
|
39
|
+
) -> dict[str, Any]:
|
|
40
|
+
"""Verify and parse in one step; raises ApiSimError(401) when the signature is invalid or stale."""
|
|
41
|
+
if not verify_webhook(secret, signature, raw_body, tolerance_seconds):
|
|
42
|
+
raise ApiSimError(401, "invalid webhook signature")
|
|
43
|
+
return json.loads(raw_body)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import hmac
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from apisim import ApiSim, ApiSimError, parse_webhook, verify_webhook
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def make_client(handler):
|
|
12
|
+
return ApiSim(api_key="sim_test", transport=httpx.MockTransport(handler))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_send_carries_auth_idempotency_and_body():
|
|
16
|
+
seen = {}
|
|
17
|
+
|
|
18
|
+
def handler(request: httpx.Request):
|
|
19
|
+
seen["url"] = str(request.url)
|
|
20
|
+
seen["headers"] = request.headers
|
|
21
|
+
seen["body"] = json.loads(request.content)
|
|
22
|
+
return httpx.Response(202, json={"id": "op1", "state": "queued"})
|
|
23
|
+
|
|
24
|
+
with make_client(handler) as apisim:
|
|
25
|
+
op = apisim.messages.send("L1", "+15555550100", "hi", idempotency_key="welcome-42")
|
|
26
|
+
assert op["id"] == "op1"
|
|
27
|
+
assert seen["url"] == "https://api.apisim.app/v1/messages"
|
|
28
|
+
assert seen["headers"]["authorization"] == "Bearer sim_test"
|
|
29
|
+
assert seen["headers"]["idempotency-key"] == "welcome-42"
|
|
30
|
+
assert seen["body"] == {"line_id": "L1", "to": "+15555550100", "body": "hi"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_errors_surface_status_detail_and_retry_after():
|
|
34
|
+
def handler(request):
|
|
35
|
+
return httpx.Response(429, json={"detail": "slow down"}, headers={"retry-after": "7"})
|
|
36
|
+
|
|
37
|
+
with pytest.raises(ApiSimError) as info, make_client(handler) as apisim:
|
|
38
|
+
apisim.lines.list()
|
|
39
|
+
assert info.value.status == 429 and info.value.detail == "slow down" and info.value.retry_after == 7.0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_events_cursor_and_wait_for_polls_until_terminal():
|
|
43
|
+
polls = {"n": 0}
|
|
44
|
+
|
|
45
|
+
def handler(request):
|
|
46
|
+
if request.url.path == "/v1/events":
|
|
47
|
+
assert request.url.params["after"] == "41"
|
|
48
|
+
return httpx.Response(200, json=[])
|
|
49
|
+
polls["n"] += 1
|
|
50
|
+
return httpx.Response(200, json={"id": "op1", "state": "accepted" if polls["n"] < 3 else "sent"})
|
|
51
|
+
|
|
52
|
+
with make_client(handler) as apisim:
|
|
53
|
+
assert apisim.events.list(41) == []
|
|
54
|
+
op = apisim.operations.wait_for("op1", interval=0)
|
|
55
|
+
assert op["state"] == "sent" and polls["n"] == 3
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_webhook_verification_uses_raw_body_and_rejects_stale_or_altered():
|
|
59
|
+
secret, t = "whsec_test", 1_730_500_000
|
|
60
|
+
raw = json.dumps({"id": "evt1", "seq": 7, "type": "sms.sent", "data": {}, "created_at": 1}).encode()
|
|
61
|
+
v1 = hmac.new(secret.encode(), f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
|
|
62
|
+
signature = f"t={t},v1={v1}"
|
|
63
|
+
assert verify_webhook(secret, signature, raw, now=t + 10)
|
|
64
|
+
assert not verify_webhook(secret, signature, raw + b" ", now=t + 10)
|
|
65
|
+
assert not verify_webhook(secret, signature, raw, now=t + 301)
|
|
66
|
+
assert not verify_webhook("other", signature, raw, now=t)
|
|
67
|
+
assert not verify_webhook(secret, "garbage", raw, now=t)
|
|
68
|
+
assert parse_webhook(secret, signature, raw, tolerance_seconds=10**10)["type"] == "sms.sent"
|
|
69
|
+
with pytest.raises(ApiSimError):
|
|
70
|
+
parse_webhook(secret, signature, raw + b"x", tolerance_seconds=10**10)
|
apisim-0.1.0/uv.lock
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
version = 1
|
|
2
|
+
revision = 5
|
|
3
|
+
requires-python = ">=3.11"
|
|
4
|
+
|
|
5
|
+
[[package]]
|
|
6
|
+
name = "anyio"
|
|
7
|
+
version = "4.16.0"
|
|
8
|
+
source = { registry = "https://pypi.org/simple" }
|
|
9
|
+
dependencies = [
|
|
10
|
+
{ name = "idna" },
|
|
11
|
+
{ name = "typing-extensions", marker = "python_full_version < '3.15'" },
|
|
12
|
+
]
|
|
13
|
+
sdist = { url = "https://files.pythonhosted.org/packages/a1/98/7e457a3b014976887a050561955c0b76e1e7e2d6e27b78ecef2ef80518f2/anyio-4.16.0.tar.gz", hash = "sha256:59bb03251907983d1603d27c3087ddb1e93299ea2163adcdba37899a4fb12ffb", size = 310971, upload-time = "2026-11-01T11:20:07.282Z" }
|
|
14
|
+
wheels = [
|
|
15
|
+
{ url = "https://files.pythonhosted.org/packages/a9/16/7757fb84c819accc7d7e9af5291b261e4abb8627ee040f99e7d35ef2457a/anyio-4.16.0-py3-none-any.whl", hash = "sha256:c66ae634e0b8228396b801741beb3294326d4b6102f7bba4041c0dfb58c29026", size = 134521, upload-time = "2026-11-01T11:20:05.59Z" },
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[[package]]
|
|
19
|
+
name = "apisim"
|
|
20
|
+
version = "0.1.0"
|
|
21
|
+
source = { editable = "." }
|
|
22
|
+
dependencies = [
|
|
23
|
+
{ name = "httpx" },
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[package.dev-dependencies]
|
|
27
|
+
dev = [
|
|
28
|
+
{ name = "pytest" },
|
|
29
|
+
{ name = "ruff" },
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[package.metadata]
|
|
33
|
+
requires-dist = [{ name = "httpx", specifier = ">=0.27" }]
|
|
34
|
+
|
|
35
|
+
[package.metadata.requires-dev]
|
|
36
|
+
dev = [
|
|
37
|
+
{ name = "pytest", specifier = ">=8" },
|
|
38
|
+
{ name = "ruff", specifier = ">=0.13" },
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[[package]]
|
|
42
|
+
name = "certifi"
|
|
43
|
+
version = "2026.10.23"
|
|
44
|
+
source = { registry = "https://pypi.org/simple" }
|
|
45
|
+
sdist = { url = "https://files.pythonhosted.org/packages/fd/11/b524cbd9bbdaca58608e4cf4053fd463417549e1959fa8d3aa18319f9555/certifi-2026.10.23.tar.gz", hash = "sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd", size = 136225, upload-time = "2026-10-23T11:36:16.478Z" }
|
|
46
|
+
wheels = [
|
|
47
|
+
{ url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl", hash = "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1", size = 135082, upload-time = "2026-10-23T11:36:14.763Z" },
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
[[package]]
|
|
51
|
+
name = "colorama"
|
|
52
|
+
version = "0.4.6"
|
|
53
|
+
source = { registry = "https://pypi.org/simple" }
|
|
54
|
+
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
|
55
|
+
wheels = [
|
|
56
|
+
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
[[package]]
|
|
60
|
+
name = "h11"
|
|
61
|
+
version = "0.16.0"
|
|
62
|
+
source = { registry = "https://pypi.org/simple" }
|
|
63
|
+
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
|
64
|
+
wheels = [
|
|
65
|
+
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
[[package]]
|
|
69
|
+
name = "httpcore"
|
|
70
|
+
version = "1.0.9"
|
|
71
|
+
source = { registry = "https://pypi.org/simple" }
|
|
72
|
+
dependencies = [
|
|
73
|
+
{ name = "certifi" },
|
|
74
|
+
{ name = "h11" },
|
|
75
|
+
]
|
|
76
|
+
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
|
77
|
+
wheels = [
|
|
78
|
+
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
[[package]]
|
|
82
|
+
name = "httpx"
|
|
83
|
+
version = "0.28.1"
|
|
84
|
+
source = { registry = "https://pypi.org/simple" }
|
|
85
|
+
dependencies = [
|
|
86
|
+
{ name = "anyio" },
|
|
87
|
+
{ name = "certifi" },
|
|
88
|
+
{ name = "httpcore" },
|
|
89
|
+
{ name = "idna" },
|
|
90
|
+
]
|
|
91
|
+
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
|
92
|
+
wheels = [
|
|
93
|
+
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
[[package]]
|
|
97
|
+
name = "idna"
|
|
98
|
+
version = "3.20"
|
|
99
|
+
source = { registry = "https://pypi.org/simple" }
|
|
100
|
+
sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
|
|
101
|
+
wheels = [
|
|
102
|
+
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
[[package]]
|
|
106
|
+
name = "iniconfig"
|
|
107
|
+
version = "2.3.1"
|
|
108
|
+
source = { registry = "https://pypi.org/simple" }
|
|
109
|
+
sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
|
|
110
|
+
wheels = [
|
|
111
|
+
{ url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
[[package]]
|
|
115
|
+
name = "packaging"
|
|
116
|
+
version = "26.3"
|
|
117
|
+
source = { registry = "https://pypi.org/simple" }
|
|
118
|
+
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
|
|
119
|
+
wheels = [
|
|
120
|
+
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
[[package]]
|
|
124
|
+
name = "pluggy"
|
|
125
|
+
version = "1.7.0"
|
|
126
|
+
source = { registry = "https://pypi.org/simple" }
|
|
127
|
+
sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" }
|
|
128
|
+
wheels = [
|
|
129
|
+
{ url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" },
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
[[package]]
|
|
133
|
+
name = "pygments"
|
|
134
|
+
version = "2.21.0"
|
|
135
|
+
source = { registry = "https://pypi.org/simple" }
|
|
136
|
+
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
|
|
137
|
+
wheels = [
|
|
138
|
+
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
[[package]]
|
|
142
|
+
name = "pytest"
|
|
143
|
+
version = "9.2.0"
|
|
144
|
+
source = { registry = "https://pypi.org/simple" }
|
|
145
|
+
dependencies = [
|
|
146
|
+
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
|
147
|
+
{ name = "iniconfig" },
|
|
148
|
+
{ name = "packaging" },
|
|
149
|
+
{ name = "pluggy" },
|
|
150
|
+
{ name = "pygments" },
|
|
151
|
+
]
|
|
152
|
+
sdist = { url = "https://files.pythonhosted.org/packages/79/42/bb4641652e58dedeac2ec51abc1f8cae4fee4ec63899a0999016229a0f2a/pytest-9.2.0.tar.gz", hash = "sha256:97c4f3b1f3c7414aa36465e09b8220f1d03627993bd51b1282e193753c80557b", size = 1797555, upload-time = "2026-10-27T21:30:20.728Z" }
|
|
153
|
+
wheels = [
|
|
154
|
+
{ url = "https://files.pythonhosted.org/packages/51/39/1f5d8c8877a0fa327db959c19341d4be61660de1ab6b7391f9eb805bcd67/pytest-9.2.0-py3-none-any.whl", hash = "sha256:07acdcf88ec174f907dc01af70bf52d49b014bbab03faeeade31c96b18c2e588", size = 411776, upload-time = "2026-10-27T21:30:19.131Z" },
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
[[package]]
|
|
158
|
+
name = "ruff"
|
|
159
|
+
version = "0.17.3"
|
|
160
|
+
source = { registry = "https://pypi.org/simple" }
|
|
161
|
+
sdist = { url = "https://files.pythonhosted.org/packages/59/73/81f140c4edd9cc323de5478e6bbd3bbcfae9a82692df1a258de8cec6a6d2/ruff-0.17.3.tar.gz", hash = "sha256:a57049c8addb3662073859bb55a7ad35aa54f8a7c9ed268911d1333c5aadaaa9", size = 5339444, upload-time = "2026-10-27T00:34:05.914Z" }
|
|
162
|
+
wheels = [
|
|
163
|
+
{ url = "https://files.pythonhosted.org/packages/9e/8c/083365310c4e79c842f25bf5a842facbd9b0c0ec336f96dffa5074fe723d/ruff-0.17.3-py3-none-linux_armv6l.whl", hash = "sha256:ab86b84cd9d05e56d329742c0cabdcbe2d1d56c98f4a04aab9b5b66196febfb4", size = 10216522, upload-time = "2026-10-27T00:33:29.822Z" },
|
|
164
|
+
{ url = "https://files.pythonhosted.org/packages/72/26/4b2edf31cfebe9605d8f6ed8f1d6f376d3a53d4c9a123e62661c64c440f0/ruff-0.17.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e407b4a6852853a9a2cdce96963e94ad9775578bb44e7561a0f2829f3cc04b11", size = 10217786, upload-time = "2026-10-27T00:33:33.949Z" },
|
|
165
|
+
{ url = "https://files.pythonhosted.org/packages/a2/7e/537aba4b7f54397a374d2f6a6fc93e9c8015e18553f31a4a7b7dbced48b5/ruff-0.17.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b75cc113b3aca388933d8296b667b1ac401a80317ea3074c9b0a66541199da29", size = 9440362, upload-time = "2026-10-27T00:33:35.784Z" },
|
|
166
|
+
{ url = "https://files.pythonhosted.org/packages/3d/66/283e39734f66c8a5ebaa93c22eb7b7515779a0b1d5a5c0ad20e05c0edb41/ruff-0.17.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab5fa7ed5ce18554ea69d850ad162cbaa0c85fc47c08d83a569f806681c9a6ad", size = 10094281, upload-time = "2026-10-27T00:33:38.216Z" },
|
|
167
|
+
{ url = "https://files.pythonhosted.org/packages/21/4c/1ffc768497bd025a32c72152771e40c0b99bddc5cc6e4f50c7949a395346/ruff-0.17.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c673c170e27834d1262e3a9c1e97bdc9b9c3b180c63d89173ce69747232474c1", size = 10022543, upload-time = "2026-10-27T00:33:40.181Z" },
|
|
168
|
+
{ url = "https://files.pythonhosted.org/packages/73/c3/f90db547301080fcb82c92abf914c8bd57b89911d9b65a6c4cc6b52d0635/ruff-0.17.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a754c0341bc9ca151b7b335988b3263921c08859f84ca219b70cee825571b6f", size = 10683921, upload-time = "2026-10-27T00:33:42.182Z" },
|
|
169
|
+
{ url = "https://files.pythonhosted.org/packages/e5/98/92d4a8a26c6c96cf1a5b34dd59036d8a71e52d650aab065c58867e964e63/ruff-0.17.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4590b9d86c0b1543ebb09094254e191cd43fe8edaf330baa035aab03da251532", size = 11373408, upload-time = "2026-10-27T00:33:44.19Z" },
|
|
170
|
+
{ url = "https://files.pythonhosted.org/packages/43/02/1f24d2e3064c9753926deeb901afa57777b37ee46799b09ddd7aee6e5145/ruff-0.17.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bbde18beccd98c29a227c63c99022e3443b99258e08326df6425dfe0443705ef", size = 10977469, upload-time = "2026-10-27T00:33:46.179Z" },
|
|
171
|
+
{ url = "https://files.pythonhosted.org/packages/f0/20/fecedea2209f574ab92033b344e01a7f0c2dc76c6d6d19a14c9f8141173f/ruff-0.17.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fa4b0f931cdbcb8a52044f370a3eb429fb42eb88807b0e800fcaa98dd42b24e", size = 10577491, upload-time = "2026-10-27T00:33:48.233Z" },
|
|
172
|
+
{ url = "https://files.pythonhosted.org/packages/33/36/b93fdf0bc6f84f0cd487aadfb0354deb40013669ea35ac3d26855de1e959/ruff-0.17.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:39405f341deec934b4289634324ad695f72df2970f9121582872eaf5b722f0f5", size = 10608748, upload-time = "2026-10-27T00:33:50.284Z" },
|
|
173
|
+
{ url = "https://files.pythonhosted.org/packages/5d/b7/7d3f5f14145daead1efb0b9a8289ed1daa100f419d60774375951723ce00/ruff-0.17.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68074094f55ca3d29ab19491f640282f7a84061c4c7977aa5094b968ca457e9a", size = 10075057, upload-time = "2026-10-27T00:33:52.074Z" },
|
|
174
|
+
{ url = "https://files.pythonhosted.org/packages/b0/ed/93ff7a5909ee56e2bd4b982deae9e0efb2724417874a90c9e41ad66c6dd7/ruff-0.17.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:27bb571ac78fef178fe5118d2827df95c2aae74a48fff2b15f6acd03af5ff5bb", size = 10022431, upload-time = "2026-10-27T00:33:54.061Z" },
|
|
175
|
+
{ url = "https://files.pythonhosted.org/packages/9b/07/a83b9eeec8c1c3c0e4eda15f06c3a91001bfd2687ea2f07b750719bf9712/ruff-0.17.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:029c37cbd3de48ca37aa12117bead2d065f15da17d66bf84e0d4274013705db2", size = 10394903, upload-time = "2026-10-27T00:33:56.158Z" },
|
|
176
|
+
{ url = "https://files.pythonhosted.org/packages/af/b2/05c39857c2526fcbebe46ad93a8dc97424656dfef14d3ef933abe1ffd331/ruff-0.17.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:692fdb46d0fd9c71e380947dc5fc72f2c3f881073bc1f739e4579376231affbe", size = 10799780, upload-time = "2026-10-27T00:33:58.204Z" },
|
|
177
|
+
{ url = "https://files.pythonhosted.org/packages/ec/d9/47171a16739eb7a353936b31c20cf54340531e68c9af7d72fd7a784e235d/ruff-0.17.3-py3-none-win32.whl", hash = "sha256:46539cb031b01fd29ae0a02142483c46640b233ee12bb1c1b690bffa1555d5ca", size = 9908481, upload-time = "2026-10-27T00:34:00.087Z" },
|
|
178
|
+
{ url = "https://files.pythonhosted.org/packages/d0/a0/f08c1631bfd81faa6a90eaf70c37c786c11bed2046302b56bb506c25e1a0/ruff-0.17.3-py3-none-win_amd64.whl", hash = "sha256:56b8ffb3f3b3cbccb8b76b71b38261b7ed982ef5d20f5b447b1b61a7f4949b24", size = 10341333, upload-time = "2026-10-27T00:34:02.099Z" },
|
|
179
|
+
{ url = "https://files.pythonhosted.org/packages/99/d5/4ad4ed31413c301430918e70d3d38ae6f097af0766cb9ab8c9d502b2a4d6/ruff-0.17.3-py3-none-win_arm64.whl", hash = "sha256:81fce74bcd6be0e9da7c67c7553fd0f1f12964a2052cd728ef917b937a5ca1d9", size = 9854332, upload-time = "2026-10-27T00:34:03.847Z" },
|
|
180
|
+
]
|
|
181
|
+
|
|
182
|
+
[[package]]
|
|
183
|
+
name = "typing-extensions"
|
|
184
|
+
version = "4.16.0"
|
|
185
|
+
source = { registry = "https://pypi.org/simple" }
|
|
186
|
+
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
|
187
|
+
wheels = [
|
|
188
|
+
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
|
189
|
+
]
|