volga-sdk 1.0.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.
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .pytest_cache/
5
+ .venv/
6
+ build/
7
+ dist/
@@ -0,0 +1,144 @@
1
+ Metadata-Version: 2.4
2
+ Name: volga-sdk
3
+ Version: 1.0.0
4
+ Summary: Official Python SDK for the Volga Public API (conversations, messages, outbound webhooks).
5
+ Project-URL: Homepage, https://hooks.volga-ai.com/v1/docs
6
+ Project-URL: Source, https://github.com/volga-ai/volga-core-lite
7
+ Author: Volga
8
+ License: MIT
9
+ Keywords: api,instagram,sdk,volga,webhooks,whatsapp
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Requires-Python: >=3.8
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest>=7; extra == 'dev'
16
+ Description-Content-Type: text/markdown
17
+
18
+ # volga
19
+
20
+ Official Python SDK for the [Volga Public API](https://hooks.volga-ai.com/v1/docs) — conversations, messages, and outbound webhooks.
21
+
22
+ - Zero dependencies (standard library only)
23
+ - Automatic retries (429 + 5xx + network) with `Retry-After` support
24
+ - Cursor auto-pagination via generators
25
+ - Built-in webhook signature verification
26
+ - Python 3.8+
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install volga-sdk
32
+ ```
33
+
34
+ (The distribution is `volga-sdk`; you still `import volga`.)
35
+
36
+ ## Quickstart
37
+
38
+ ```python
39
+ import os
40
+ from volga import VolgaClient
41
+
42
+ volga = VolgaClient(api_key=os.environ["VOLGA_API_KEY"])
43
+
44
+ # List conversations
45
+ page = volga.conversations.list(channel="whatsapp", limit=25)
46
+
47
+ # Send a reply (idempotent retries)
48
+ import uuid
49
+ volga.messages.send(
50
+ conversation_id=page["data"][0]["id"],
51
+ text="Thanks for reaching out!",
52
+ idempotency_key=str(uuid.uuid4()),
53
+ )
54
+ ```
55
+
56
+ ## Pagination
57
+
58
+ ```python
59
+ # One page at a time
60
+ page = volga.messages.list(conversation_id="c_123")
61
+ print(page["data"], page["has_more"], page["next_cursor"])
62
+
63
+ # Or auto-paginate every item
64
+ for message in volga.messages.iterate(conversation_id="c_123"):
65
+ print(message["id"], message["text"])
66
+ ```
67
+
68
+ ## Webhooks
69
+
70
+ Register an endpoint (the signing `secret` is returned **once**):
71
+
72
+ ```python
73
+ endpoint = volga.webhook_endpoints.create(
74
+ url="https://example.com/volga/webhooks",
75
+ event_types=["message.received", "conversation.created"],
76
+ )
77
+ # store endpoint["secret"] securely
78
+ ```
79
+
80
+ Verify and parse incoming deliveries (pass the **raw** request body):
81
+
82
+ ```python
83
+ from volga import construct_event, VolgaSignatureVerificationError
84
+
85
+ @app.post("/volga/webhooks")
86
+ def handle(request):
87
+ try:
88
+ event = construct_event(
89
+ secret=os.environ["VOLGA_WEBHOOK_SECRET"],
90
+ payload=request.get_data(), # raw bytes, not parsed JSON
91
+ signature_header=request.headers.get("Volga-Signature"),
92
+ )
93
+ except VolgaSignatureVerificationError:
94
+ return "", 400
95
+ # handle event["type"] / event["data"] ...
96
+ return "", 200
97
+ ```
98
+
99
+ Deliveries are **at-least-once** — deduplicate on the event `id` (or the
100
+ `Volga-Delivery-Id` header).
101
+
102
+ ## Errors
103
+
104
+ ```python
105
+ from volga import VolgaRateLimitError, VolgaPermissionError, VolgaApiError
106
+
107
+ try:
108
+ volga.messages.send(conversation_id=cid, text=text)
109
+ except VolgaRateLimitError as e:
110
+ time.sleep(e.retry_after_sec or 1)
111
+ except VolgaPermissionError:
112
+ ... # the key is missing a scope
113
+ except VolgaApiError as e:
114
+ print(e.status, e.code, e.message, e.trace_id)
115
+ ```
116
+
117
+ | Class | Status |
118
+ | --- | --- |
119
+ | `VolgaInvalidRequestError` | 400 / 422 |
120
+ | `VolgaAuthenticationError` | 401 |
121
+ | `VolgaPaymentRequiredError` | 402 |
122
+ | `VolgaPermissionError` | 403 |
123
+ | `VolgaNotFoundError` | 404 |
124
+ | `VolgaConflictError` | 409 |
125
+ | `VolgaRateLimitError` | 429 |
126
+ | `VolgaServerError` | 5xx |
127
+ | `VolgaConnectionError` / `VolgaTimeoutError` | no response |
128
+
129
+ ## Configuration
130
+
131
+ ```python
132
+ VolgaClient(
133
+ api_key="vk_live_…",
134
+ base_url="https://hooks.volga-ai.com/v1", # default
135
+ timeout=30.0, # default
136
+ max_retries=2, # default
137
+ retry_base_delay=0.5,
138
+ retry_max_delay=8.0,
139
+ )
140
+ ```
141
+
142
+ ## License
143
+
144
+ MIT
@@ -0,0 +1,127 @@
1
+ # volga
2
+
3
+ Official Python SDK for the [Volga Public API](https://hooks.volga-ai.com/v1/docs) — conversations, messages, and outbound webhooks.
4
+
5
+ - Zero dependencies (standard library only)
6
+ - Automatic retries (429 + 5xx + network) with `Retry-After` support
7
+ - Cursor auto-pagination via generators
8
+ - Built-in webhook signature verification
9
+ - Python 3.8+
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install volga-sdk
15
+ ```
16
+
17
+ (The distribution is `volga-sdk`; you still `import volga`.)
18
+
19
+ ## Quickstart
20
+
21
+ ```python
22
+ import os
23
+ from volga import VolgaClient
24
+
25
+ volga = VolgaClient(api_key=os.environ["VOLGA_API_KEY"])
26
+
27
+ # List conversations
28
+ page = volga.conversations.list(channel="whatsapp", limit=25)
29
+
30
+ # Send a reply (idempotent retries)
31
+ import uuid
32
+ volga.messages.send(
33
+ conversation_id=page["data"][0]["id"],
34
+ text="Thanks for reaching out!",
35
+ idempotency_key=str(uuid.uuid4()),
36
+ )
37
+ ```
38
+
39
+ ## Pagination
40
+
41
+ ```python
42
+ # One page at a time
43
+ page = volga.messages.list(conversation_id="c_123")
44
+ print(page["data"], page["has_more"], page["next_cursor"])
45
+
46
+ # Or auto-paginate every item
47
+ for message in volga.messages.iterate(conversation_id="c_123"):
48
+ print(message["id"], message["text"])
49
+ ```
50
+
51
+ ## Webhooks
52
+
53
+ Register an endpoint (the signing `secret` is returned **once**):
54
+
55
+ ```python
56
+ endpoint = volga.webhook_endpoints.create(
57
+ url="https://example.com/volga/webhooks",
58
+ event_types=["message.received", "conversation.created"],
59
+ )
60
+ # store endpoint["secret"] securely
61
+ ```
62
+
63
+ Verify and parse incoming deliveries (pass the **raw** request body):
64
+
65
+ ```python
66
+ from volga import construct_event, VolgaSignatureVerificationError
67
+
68
+ @app.post("/volga/webhooks")
69
+ def handle(request):
70
+ try:
71
+ event = construct_event(
72
+ secret=os.environ["VOLGA_WEBHOOK_SECRET"],
73
+ payload=request.get_data(), # raw bytes, not parsed JSON
74
+ signature_header=request.headers.get("Volga-Signature"),
75
+ )
76
+ except VolgaSignatureVerificationError:
77
+ return "", 400
78
+ # handle event["type"] / event["data"] ...
79
+ return "", 200
80
+ ```
81
+
82
+ Deliveries are **at-least-once** — deduplicate on the event `id` (or the
83
+ `Volga-Delivery-Id` header).
84
+
85
+ ## Errors
86
+
87
+ ```python
88
+ from volga import VolgaRateLimitError, VolgaPermissionError, VolgaApiError
89
+
90
+ try:
91
+ volga.messages.send(conversation_id=cid, text=text)
92
+ except VolgaRateLimitError as e:
93
+ time.sleep(e.retry_after_sec or 1)
94
+ except VolgaPermissionError:
95
+ ... # the key is missing a scope
96
+ except VolgaApiError as e:
97
+ print(e.status, e.code, e.message, e.trace_id)
98
+ ```
99
+
100
+ | Class | Status |
101
+ | --- | --- |
102
+ | `VolgaInvalidRequestError` | 400 / 422 |
103
+ | `VolgaAuthenticationError` | 401 |
104
+ | `VolgaPaymentRequiredError` | 402 |
105
+ | `VolgaPermissionError` | 403 |
106
+ | `VolgaNotFoundError` | 404 |
107
+ | `VolgaConflictError` | 409 |
108
+ | `VolgaRateLimitError` | 429 |
109
+ | `VolgaServerError` | 5xx |
110
+ | `VolgaConnectionError` / `VolgaTimeoutError` | no response |
111
+
112
+ ## Configuration
113
+
114
+ ```python
115
+ VolgaClient(
116
+ api_key="vk_live_…",
117
+ base_url="https://hooks.volga-ai.com/v1", # default
118
+ timeout=30.0, # default
119
+ max_retries=2, # default
120
+ retry_base_delay=0.5,
121
+ retry_max_delay=8.0,
122
+ )
123
+ ```
124
+
125
+ ## License
126
+
127
+ MIT
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ # Distribution name on PyPI (the bare `volga` is taken by an unrelated project).
7
+ # The import name stays `volga` (see [tool.hatch.build.targets.wheel]).
8
+ name = "volga-sdk"
9
+ version = "1.0.0"
10
+ description = "Official Python SDK for the Volga Public API (conversations, messages, outbound webhooks)."
11
+ readme = "README.md"
12
+ requires-python = ">=3.8"
13
+ license = { text = "MIT" }
14
+ keywords = ["volga", "whatsapp", "instagram", "webhooks", "api", "sdk"]
15
+ authors = [{ name = "Volga" }]
16
+ classifiers = [
17
+ "Programming Language :: Python :: 3",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Operating System :: OS Independent",
20
+ ]
21
+ dependencies = []
22
+
23
+ [project.optional-dependencies]
24
+ dev = ["pytest>=7"]
25
+
26
+ [project.urls]
27
+ Homepage = "https://hooks.volga-ai.com/v1/docs"
28
+ Source = "https://github.com/volga-ai/volga-core-lite"
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["volga"]
32
+
33
+ [tool.pytest.ini_options]
34
+ testpaths = ["tests"]
@@ -0,0 +1,61 @@
1
+ import json
2
+ from typing import Any, Dict, List, Optional
3
+
4
+ import pytest
5
+
6
+ from volga import VolgaClient
7
+ from volga._http import HttpResponse
8
+
9
+
10
+ class FakeHttp:
11
+ """Replays a queue of canned responses and records every call."""
12
+
13
+ def __init__(self, responses: List[Dict[str, Any]]) -> None:
14
+ self.responses = responses
15
+ self.calls: List[Dict[str, Any]] = []
16
+
17
+ def __call__(self, method, url, headers, body, timeout): # noqa: ANN001
18
+ self.calls.append(
19
+ {"method": method, "url": url, "headers": headers, "body": body}
20
+ )
21
+ idx = min(len(self.calls) - 1, len(self.responses) - 1)
22
+ spec = self.responses[idx] if self.responses else {}
23
+ if "raise" in spec:
24
+ raise spec["raise"]
25
+ status = spec.get("status", 200)
26
+ raw = spec.get("body")
27
+ if raw is None:
28
+ text = ""
29
+ elif isinstance(raw, str):
30
+ text = raw
31
+ else:
32
+ text = json.dumps(raw)
33
+ return HttpResponse(status, spec.get("headers", {}), text)
34
+
35
+
36
+ def make_client(responses: List[Dict[str, Any]], **overrides):
37
+ http = FakeHttp(responses)
38
+ client = VolgaClient(
39
+ api_key="vk_test_abc",
40
+ base_url="https://api.test/v1",
41
+ http=http,
42
+ retry_base_delay=0,
43
+ retry_max_delay=0,
44
+ sleep=lambda _s: None,
45
+ **overrides,
46
+ )
47
+ return client, http
48
+
49
+
50
+ @pytest.fixture
51
+ def client_factory():
52
+ return make_client
53
+
54
+
55
+ def page(data: List[Any], next_cursor: Optional[str] = None) -> Dict[str, Any]:
56
+ return {
57
+ "object": "list",
58
+ "data": data,
59
+ "has_more": next_cursor is not None,
60
+ "next_cursor": next_cursor,
61
+ }
@@ -0,0 +1,113 @@
1
+ import json
2
+
3
+ import pytest
4
+
5
+ from volga import VolgaClient
6
+ from tests.conftest import make_client, page
7
+
8
+
9
+ def test_requires_api_key():
10
+ with pytest.raises(Exception) as exc:
11
+ VolgaClient(api_key="")
12
+ assert "api key is required" in str(exc.value).lower()
13
+
14
+
15
+ def test_sends_bearer_and_accept():
16
+ client, http = make_client([{"body": page([])}])
17
+ client.conversations.list()
18
+ headers = http.calls[0]["headers"]
19
+ assert headers["Authorization"] == "Bearer vk_test_abc"
20
+ assert headers["Accept"] == "application/json"
21
+ assert headers["User-Agent"].startswith("volga-sdk-python/")
22
+
23
+
24
+ def test_conversations_list_builds_query_and_omits_none():
25
+ client, http = make_client([{"body": page([{"id": "c1"}])}])
26
+ res = client.conversations.list(channel="instagram", limit=50)
27
+ assert res["data"][0]["id"] == "c1"
28
+ assert http.calls[0]["url"] == "https://api.test/v1/conversations?limit=50&channel=instagram"
29
+ assert "cursor" not in http.calls[0]["url"]
30
+
31
+
32
+ def test_retrieve_url_encodes_id():
33
+ client, http = make_client([{"body": {"id": "a/b"}}])
34
+ client.conversations.retrieve("a/b")
35
+ assert http.calls[0]["url"] == "https://api.test/v1/conversations/a%2Fb"
36
+ assert http.calls[0]["method"] == "GET"
37
+
38
+
39
+ def test_messages_list_requires_conversation_id():
40
+ client, http = make_client([{"body": page([{"id": "m1"}])}])
41
+ client.messages.list("c1", limit=10)
42
+ assert http.calls[0]["url"] == "https://api.test/v1/messages?conversationId=c1&limit=10"
43
+
44
+
45
+ def test_messages_send_posts_body_and_idempotency_key():
46
+ client, http = make_client([{"status": 201, "body": {"id": "m9"}}])
47
+ sent = client.messages.send(conversation_id="c1", text="hello", idempotency_key="idem-123")
48
+ assert sent["id"] == "m9"
49
+ call = http.calls[0]
50
+ assert call["method"] == "POST"
51
+ assert json.loads(call["body"].decode()) == {"conversationId": "c1", "text": "hello"}
52
+ assert call["headers"]["Idempotency-Key"] == "idem-123"
53
+ assert call["headers"]["Content-Type"] == "application/json"
54
+
55
+
56
+ def test_messages_send_without_idempotency_key():
57
+ client, http = make_client([{"status": 201, "body": {"id": "m1"}}])
58
+ client.messages.send(conversation_id="c1", text="hi")
59
+ assert "Idempotency-Key" not in http.calls[0]["headers"]
60
+
61
+
62
+ def test_webhook_endpoint_create_returns_secret():
63
+ client, http = make_client(
64
+ [{"status": 201, "body": {"id": "we_1", "secret": "whsec_xyz"}}]
65
+ )
66
+ ep = client.webhook_endpoints.create(
67
+ url="https://x.test/hook", event_types=["message.sent"]
68
+ )
69
+ assert ep["secret"] == "whsec_xyz"
70
+ assert http.calls[0]["method"] == "POST"
71
+
72
+
73
+ def test_webhook_endpoint_update_rotate_secret_is_query_not_body():
74
+ client, http = make_client([{"body": {"id": "we_1", "secret": "whsec_new"}}])
75
+ client.webhook_endpoints.update("we_1", is_active=False, rotate_secret=True)
76
+ call = http.calls[0]
77
+ assert call["method"] == "PATCH"
78
+ assert call["url"] == "https://api.test/v1/webhook-endpoints/we_1?rotateSecret=true"
79
+ assert json.loads(call["body"].decode()) == {"isActive": False}
80
+
81
+
82
+ def test_webhook_endpoint_update_can_clear_description():
83
+ client, http = make_client([{"body": {"id": "we_1"}}])
84
+ client.webhook_endpoints.update("we_1", description=None)
85
+ assert json.loads(http.calls[0]["body"].decode()) == {"description": None}
86
+
87
+
88
+ def test_webhook_endpoint_update_omits_untouched_description():
89
+ client, http = make_client([{"body": {"id": "we_1"}}])
90
+ client.webhook_endpoints.update("we_1", is_active=True)
91
+ assert json.loads(http.calls[0]["body"].decode()) == {"isActive": True}
92
+
93
+
94
+ def test_webhook_endpoint_delete():
95
+ client, http = make_client([{"body": {"object": "webhook_endpoint", "id": "we_1", "deleted": True}}])
96
+ res = client.webhook_endpoints.delete("we_1")
97
+ assert res["deleted"] is True
98
+ assert http.calls[0]["method"] == "DELETE"
99
+
100
+
101
+ def test_webhook_delivery_replay():
102
+ client, http = make_client([{"body": {"id": "wd_1", "status": "PENDING"}}])
103
+ res = client.webhook_deliveries.replay("wd_1")
104
+ assert res["status"] == "PENDING"
105
+ assert http.calls[0]["method"] == "POST"
106
+ assert http.calls[0]["url"] == "https://api.test/v1/webhook-deliveries/wd_1/replay"
107
+
108
+
109
+ def test_get_openapi_spec():
110
+ client, http = make_client([{"body": {"openapi": "3.1.0"}}])
111
+ spec = client.get_openapi_spec()
112
+ assert spec["openapi"] == "3.1.0"
113
+ assert http.calls[0]["url"] == "https://api.test/v1/openapi.json"
@@ -0,0 +1,144 @@
1
+ import pytest
2
+
3
+ from volga import (
4
+ VolgaApiError,
5
+ VolgaAuthenticationError,
6
+ VolgaConflictError,
7
+ VolgaConnectionError,
8
+ VolgaNotFoundError,
9
+ VolgaPermissionError,
10
+ VolgaRateLimitError,
11
+ VolgaServerError,
12
+ VolgaTimeoutError,
13
+ )
14
+ from volga.errors import VolgaInvalidRequestError
15
+ from tests.conftest import make_client, page
16
+
17
+ EMPTY = page([])
18
+
19
+
20
+ def err(code, message):
21
+ return {"error": message, "errorCode": code}
22
+
23
+
24
+ @pytest.mark.parametrize(
25
+ "status,ctor",
26
+ [
27
+ (400, VolgaInvalidRequestError),
28
+ (401, VolgaAuthenticationError),
29
+ (403, VolgaPermissionError),
30
+ (404, VolgaNotFoundError),
31
+ (409, VolgaConflictError),
32
+ (422, VolgaInvalidRequestError),
33
+ (500, VolgaServerError),
34
+ ],
35
+ )
36
+ def test_error_mapping(status, ctor):
37
+ client, _ = make_client([{"status": status, "body": err("X", "boom")}], max_retries=0)
38
+ with pytest.raises(ctor):
39
+ client.conversations.list()
40
+
41
+
42
+ def test_reads_message_and_code():
43
+ client, _ = make_client([{"status": 403, "body": err("INSUFFICIENT_SCOPE", "missing scope")}])
44
+ with pytest.raises(VolgaApiError) as exc:
45
+ client.conversations.list()
46
+ assert exc.value.status == 403
47
+ assert exc.value.code == "INSUFFICIENT_SCOPE"
48
+ assert exc.value.message == "missing scope"
49
+
50
+
51
+ def test_exposes_trace_id_and_details():
52
+ body = {"error": "bad", "errorCode": "INVALID_PAYLOAD", "traceId": "tr_1", "details": {"f": 1}}
53
+ client, _ = make_client([{"status": 400, "body": body}])
54
+ with pytest.raises(VolgaApiError) as exc:
55
+ client.messages.send(conversation_id="c", text="x")
56
+ assert exc.value.trace_id == "tr_1"
57
+ assert exc.value.details == {"f": 1}
58
+
59
+
60
+ def test_non_json_body_falls_back_to_synthetic_code():
61
+ client, _ = make_client([{"status": 404, "body": "Not Found"}], max_retries=0)
62
+ with pytest.raises(VolgaApiError) as exc:
63
+ client.conversations.retrieve("x")
64
+ assert exc.value.code == "HTTP_404"
65
+
66
+
67
+ def test_retries_429_then_succeeds():
68
+ client, http = make_client(
69
+ [{"status": 429, "headers": {"Retry-After": "0"}, "body": err("RATE_LIMITED", "slow")},
70
+ {"body": EMPTY}]
71
+ )
72
+ client.conversations.list()
73
+ assert len(http.calls) == 2
74
+
75
+
76
+ def test_surfaces_retry_after_when_exhausted():
77
+ client, _ = make_client(
78
+ [{"status": 429, "headers": {"Retry-After": "7"}, "body": err("RATE_LIMITED", "slow")}],
79
+ max_retries=1,
80
+ )
81
+ with pytest.raises(VolgaRateLimitError) as exc:
82
+ client.conversations.list()
83
+ assert exc.value.retry_after_sec == 7
84
+
85
+
86
+ def test_retries_get_on_500():
87
+ client, http = make_client([{"status": 500, "body": err("X", "e")}, {"body": EMPTY}])
88
+ client.conversations.list()
89
+ assert len(http.calls) == 2
90
+
91
+
92
+ def test_does_not_retry_post_without_idempotency_on_500():
93
+ client, http = make_client([{"status": 500, "body": err("X", "e")}])
94
+ with pytest.raises(VolgaServerError):
95
+ client.messages.send(conversation_id="c", text="x")
96
+ assert len(http.calls) == 1
97
+
98
+
99
+ def test_retries_post_with_idempotency_on_500():
100
+ client, http = make_client(
101
+ [{"status": 500, "body": err("X", "e")}, {"status": 201, "body": {"id": "m1"}}]
102
+ )
103
+ client.messages.send(conversation_id="c", text="x", idempotency_key="k1")
104
+ assert len(http.calls) == 2
105
+
106
+
107
+ def test_does_not_retry_4xx_other_than_429():
108
+ client, http = make_client([{"status": 400, "body": err("INVALID", "bad")}])
109
+ with pytest.raises(VolgaApiError):
110
+ client.conversations.list()
111
+ assert len(http.calls) == 1
112
+
113
+
114
+ def test_retries_network_error_on_get():
115
+ client, http = make_client(
116
+ [{"raise": VolgaConnectionError("reset")}, {"body": EMPTY}]
117
+ )
118
+ client.conversations.list()
119
+ assert len(http.calls) == 2
120
+
121
+
122
+ def test_wraps_persistent_network_failure():
123
+ client, _ = make_client([{"raise": VolgaConnectionError("reset")}], max_retries=1)
124
+ with pytest.raises(VolgaConnectionError):
125
+ client.conversations.list()
126
+
127
+
128
+ def test_timeout_on_get_retries_then_raises():
129
+ client, http = make_client(
130
+ [{"raise": VolgaTimeoutError("timed out")}], max_retries=2
131
+ )
132
+ with pytest.raises(VolgaTimeoutError):
133
+ client.conversations.list()
134
+ # initial attempt + 2 retries (timeout is a network-level failure on a GET)
135
+ assert len(http.calls) == 3
136
+
137
+
138
+ def test_timeout_on_post_without_idempotency_is_not_retried():
139
+ client, http = make_client(
140
+ [{"raise": VolgaTimeoutError("timed out")}], max_retries=2
141
+ )
142
+ with pytest.raises(VolgaTimeoutError):
143
+ client.messages.send(conversation_id="c", text="x")
144
+ assert len(http.calls) == 1
@@ -0,0 +1,63 @@
1
+ import json
2
+ import os
3
+
4
+ from volga import OPERATIONS, WEBHOOK_EVENT_TYPES, VolgaClient, operation_key
5
+
6
+ REPO_ROOT = os.path.abspath(
7
+ os.path.join(os.path.dirname(__file__), "..", "..", "..")
8
+ )
9
+ OPENAPI_PATH = os.path.join(REPO_ROOT, "openapi", "openapi.json")
10
+
11
+ HTTP_METHODS = {"get", "post", "put", "patch", "delete"}
12
+
13
+
14
+ def _spec_operation_keys():
15
+ with open(OPENAPI_PATH, "r", encoding="utf-8") as fh:
16
+ spec = json.load(fh)
17
+ keys = set()
18
+ for path, item in spec["paths"].items():
19
+ for method in item:
20
+ if method.lower() in HTTP_METHODS:
21
+ keys.add("{} {}".format(method.upper(), path))
22
+ return keys
23
+
24
+
25
+ def _spec_webhook_event_types():
26
+ with open(OPENAPI_PATH, "r", encoding="utf-8") as fh:
27
+ spec = json.load(fh)
28
+ return set(spec.get("webhooks", {}).keys())
29
+
30
+
31
+ def test_sdk_declares_exactly_the_documented_webhook_events():
32
+ spec_events = _spec_webhook_event_types()
33
+ sdk_events = set(WEBHOOK_EVENT_TYPES)
34
+ assert sdk_events == spec_events, (
35
+ "webhook event drift — sdk: {}, spec: {}".format(sdk_events, spec_events)
36
+ )
37
+
38
+
39
+ def test_operation_keys_are_unique():
40
+ keys = [operation_key(op) for op in OPERATIONS]
41
+ assert len(set(keys)) == len(keys)
42
+
43
+
44
+ def test_sdk_covers_every_documented_operation():
45
+ spec_keys = _spec_operation_keys()
46
+ sdk_keys = {operation_key(op) for op in OPERATIONS}
47
+ missing = spec_keys - sdk_keys
48
+ phantom = sdk_keys - spec_keys
49
+ assert not missing, "documented operations not implemented in the SDK: {}".format(missing)
50
+ assert not phantom, "SDK operations not in the contract: {}".format(phantom)
51
+
52
+
53
+ def test_every_operation_resolves_to_a_real_method():
54
+ client = VolgaClient(api_key="vk_test_x")
55
+ for op in OPERATIONS:
56
+ if op["resource"] == "(client)":
57
+ assert callable(getattr(client, op["fn"], None)), op["fn"]
58
+ continue
59
+ resource = getattr(client, op["resource"], None)
60
+ assert resource is not None, "missing resource {}".format(op["resource"])
61
+ assert callable(getattr(resource, op["fn"], None)), "{}.{}".format(
62
+ op["resource"], op["fn"]
63
+ )