blueticks 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.
- blueticks-1.0.0/LICENSE +21 -0
- blueticks-1.0.0/PKG-INFO +75 -0
- blueticks-1.0.0/README.md +22 -0
- blueticks-1.0.0/pyproject.toml +65 -0
- blueticks-1.0.0/setup.cfg +4 -0
- blueticks-1.0.0/src/blueticks/__init__.py +25 -0
- blueticks-1.0.0/src/blueticks/_base_resource.py +13 -0
- blueticks-1.0.0/src/blueticks/_client.py +101 -0
- blueticks-1.0.0/src/blueticks/_errors.py +120 -0
- blueticks-1.0.0/src/blueticks/_transport.py +141 -0
- blueticks-1.0.0/src/blueticks/_version.py +1 -0
- blueticks-1.0.0/src/blueticks/py.typed +0 -0
- blueticks-1.0.0/src/blueticks/resources/__init__.py +4 -0
- blueticks-1.0.0/src/blueticks/resources/account.py +14 -0
- blueticks-1.0.0/src/blueticks/resources/ping.py +14 -0
- blueticks-1.0.0/src/blueticks/types/__init__.py +4 -0
- blueticks-1.0.0/src/blueticks/types/account.py +17 -0
- blueticks-1.0.0/src/blueticks/types/ping.py +13 -0
- blueticks-1.0.0/src/blueticks.egg-info/PKG-INFO +75 -0
- blueticks-1.0.0/src/blueticks.egg-info/SOURCES.txt +26 -0
- blueticks-1.0.0/src/blueticks.egg-info/dependency_links.txt +1 -0
- blueticks-1.0.0/src/blueticks.egg-info/requires.txt +8 -0
- blueticks-1.0.0/src/blueticks.egg-info/top_level.txt +1 -0
- blueticks-1.0.0/tests/test_account.py +89 -0
- blueticks-1.0.0/tests/test_client.py +52 -0
- blueticks-1.0.0/tests/test_errors.py +101 -0
- blueticks-1.0.0/tests/test_ping.py +66 -0
- blueticks-1.0.0/tests/test_transport.py +195 -0
blueticks-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 serenix-com
|
|
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.
|
blueticks-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: blueticks
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official Python client for the Blueticks API.
|
|
5
|
+
Author-email: Blueticks <support@blueticks.co>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 serenix-com
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://blueticks.co
|
|
29
|
+
Project-URL: Documentation, https://docs.blueticks.co
|
|
30
|
+
Project-URL: Source, https://github.com/serenix-com/blueticks-python
|
|
31
|
+
Project-URL: Changelog, https://github.com/serenix-com/blueticks-python/blob/main/CHANGELOG.md
|
|
32
|
+
Keywords: blueticks,whatsapp,api,sdk
|
|
33
|
+
Classifier: Development Status :: 4 - Beta
|
|
34
|
+
Classifier: Intended Audience :: Developers
|
|
35
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
36
|
+
Classifier: Programming Language :: Python :: 3
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
41
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
42
|
+
Requires-Python: >=3.9
|
|
43
|
+
Description-Content-Type: text/markdown
|
|
44
|
+
License-File: LICENSE
|
|
45
|
+
Requires-Dist: httpx<1.0,>=0.27
|
|
46
|
+
Requires-Dist: pydantic<3.0,>=2.6
|
|
47
|
+
Provides-Extra: dev
|
|
48
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
49
|
+
Requires-Dist: pytest-httpx>=0.30; extra == "dev"
|
|
50
|
+
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
51
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
52
|
+
Dynamic: license-file
|
|
53
|
+
|
|
54
|
+
# blueticks — Python client for the Blueticks API
|
|
55
|
+
|
|
56
|
+
Official Python SDK for [Blueticks](https://blueticks.co).
|
|
57
|
+
|
|
58
|
+
## Install
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pip install blueticks
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Quickstart
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from blueticks import Blueticks
|
|
68
|
+
|
|
69
|
+
client = Blueticks(api_key="bt_live_...")
|
|
70
|
+
ping = client.ping()
|
|
71
|
+
account = client.account.retrieve()
|
|
72
|
+
print(account.name)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
See https://docs.blueticks.co for full documentation.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# blueticks — Python client for the Blueticks API
|
|
2
|
+
|
|
3
|
+
Official Python SDK for [Blueticks](https://blueticks.co).
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install blueticks
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quickstart
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from blueticks import Blueticks
|
|
15
|
+
|
|
16
|
+
client = Blueticks(api_key="bt_live_...")
|
|
17
|
+
ping = client.ping()
|
|
18
|
+
account = client.account.retrieve()
|
|
19
|
+
print(account.name)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
See https://docs.blueticks.co for full documentation.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "blueticks"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Official Python client for the Blueticks API."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { file = "LICENSE" }
|
|
12
|
+
authors = [{ name = "Blueticks", email = "support@blueticks.co" }]
|
|
13
|
+
keywords = ["blueticks", "whatsapp", "api", "sdk"]
|
|
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 :: Software Development :: Libraries :: Python Modules",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"httpx>=0.27,<1.0",
|
|
27
|
+
"pydantic>=2.6,<3.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
dev = [
|
|
32
|
+
"pytest>=8.0",
|
|
33
|
+
"pytest-httpx>=0.30",
|
|
34
|
+
"ruff>=0.5",
|
|
35
|
+
"mypy>=1.10",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://blueticks.co"
|
|
40
|
+
Documentation = "https://docs.blueticks.co"
|
|
41
|
+
Source = "https://github.com/serenix-com/blueticks-python"
|
|
42
|
+
Changelog = "https://github.com/serenix-com/blueticks-python/blob/main/CHANGELOG.md"
|
|
43
|
+
|
|
44
|
+
[tool.setuptools.dynamic]
|
|
45
|
+
version = { attr = "blueticks._version.__version__" }
|
|
46
|
+
|
|
47
|
+
[tool.setuptools.packages.find]
|
|
48
|
+
where = ["src"]
|
|
49
|
+
|
|
50
|
+
[tool.setuptools.package-data]
|
|
51
|
+
blueticks = ["py.typed"]
|
|
52
|
+
|
|
53
|
+
[tool.ruff]
|
|
54
|
+
line-length = 100
|
|
55
|
+
target-version = "py39"
|
|
56
|
+
|
|
57
|
+
[tool.ruff.lint]
|
|
58
|
+
select = ["A", "B", "E", "F", "I", "UP", "W"]
|
|
59
|
+
|
|
60
|
+
[tool.mypy]
|
|
61
|
+
python_version = "3.9"
|
|
62
|
+
strict = true
|
|
63
|
+
warn_return_any = true
|
|
64
|
+
warn_unused_ignores = true
|
|
65
|
+
files = ["src/blueticks"]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from blueticks._client import Blueticks
|
|
2
|
+
from blueticks._errors import (
|
|
3
|
+
APIConnectionError,
|
|
4
|
+
APIError,
|
|
5
|
+
AuthenticationError,
|
|
6
|
+
BadRequestError,
|
|
7
|
+
BluetickError,
|
|
8
|
+
NotFoundError,
|
|
9
|
+
PermissionDeniedError,
|
|
10
|
+
RateLimitError,
|
|
11
|
+
)
|
|
12
|
+
from blueticks._version import __version__
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"APIConnectionError",
|
|
16
|
+
"APIError",
|
|
17
|
+
"AuthenticationError",
|
|
18
|
+
"BadRequestError",
|
|
19
|
+
"Blueticks",
|
|
20
|
+
"BluetickError",
|
|
21
|
+
"NotFoundError",
|
|
22
|
+
"PermissionDeniedError",
|
|
23
|
+
"RateLimitError",
|
|
24
|
+
"__version__",
|
|
25
|
+
]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from blueticks._client import Blueticks
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BaseResource:
|
|
10
|
+
"""Shared plumbing for all resource classes. Holds a reference to the parent client."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, client: Blueticks) -> None:
|
|
13
|
+
self._client = client
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from blueticks.types.ping import Ping
|
|
10
|
+
|
|
11
|
+
from blueticks._errors import BluetickError
|
|
12
|
+
from blueticks._transport import Transport
|
|
13
|
+
from blueticks._version import __version__
|
|
14
|
+
|
|
15
|
+
_DEFAULT_BASE_URL = "https://api.blueticks.co"
|
|
16
|
+
_DEFAULT_TIMEOUT = 30.0
|
|
17
|
+
_DEFAULT_MAX_RETRIES = 2
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Blueticks:
|
|
21
|
+
"""Synchronous client for the Blueticks API."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
api_key: str | None = None,
|
|
26
|
+
*,
|
|
27
|
+
base_url: str | None = None,
|
|
28
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
29
|
+
max_retries: int = _DEFAULT_MAX_RETRIES,
|
|
30
|
+
user_agent: str | None = None,
|
|
31
|
+
_http_transport: httpx.BaseTransport | None = None,
|
|
32
|
+
) -> None:
|
|
33
|
+
resolved_key = api_key if api_key is not None else os.environ.get("BLUETICKS_API_KEY")
|
|
34
|
+
if not resolved_key:
|
|
35
|
+
raise BluetickError(
|
|
36
|
+
status_code=None,
|
|
37
|
+
code="authentication_required",
|
|
38
|
+
message=(
|
|
39
|
+
"No api_key provided. Pass Blueticks(api_key=...) "
|
|
40
|
+
"or set BLUETICKS_API_KEY in your environment."
|
|
41
|
+
),
|
|
42
|
+
request_id=None,
|
|
43
|
+
)
|
|
44
|
+
self._api_key = resolved_key
|
|
45
|
+
|
|
46
|
+
resolved_base = (
|
|
47
|
+
base_url
|
|
48
|
+
if base_url is not None
|
|
49
|
+
else os.environ.get("BLUETICKS_BASE_URL") or _DEFAULT_BASE_URL
|
|
50
|
+
)
|
|
51
|
+
self._base_url = resolved_base
|
|
52
|
+
|
|
53
|
+
self._transport = Transport(
|
|
54
|
+
api_key=self._api_key,
|
|
55
|
+
base_url=self._base_url,
|
|
56
|
+
timeout=timeout,
|
|
57
|
+
max_retries=max_retries,
|
|
58
|
+
user_agent_suffix=user_agent,
|
|
59
|
+
version=__version__,
|
|
60
|
+
http_transport=_http_transport,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Lazy-imported to avoid import cycles and to keep core import cheap.
|
|
64
|
+
from blueticks.resources.account import AccountResource
|
|
65
|
+
|
|
66
|
+
self.account = AccountResource(self)
|
|
67
|
+
|
|
68
|
+
# -- Public API ----------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
def ping(self) -> Ping:
|
|
71
|
+
from blueticks.resources.ping import PingResource
|
|
72
|
+
|
|
73
|
+
return PingResource(self).retrieve()
|
|
74
|
+
|
|
75
|
+
def close(self) -> None:
|
|
76
|
+
self._transport.close()
|
|
77
|
+
|
|
78
|
+
def __enter__(self) -> Blueticks:
|
|
79
|
+
return self
|
|
80
|
+
|
|
81
|
+
def __exit__(self, *_: Any) -> None:
|
|
82
|
+
self.close()
|
|
83
|
+
|
|
84
|
+
# -- Internal ------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def _request(
|
|
87
|
+
self,
|
|
88
|
+
method: str,
|
|
89
|
+
path: str,
|
|
90
|
+
*,
|
|
91
|
+
params: dict[str, Any] | None = None,
|
|
92
|
+
body: dict[str, Any] | None = None,
|
|
93
|
+
idempotency_key: str | None = None,
|
|
94
|
+
) -> Any:
|
|
95
|
+
return self._transport.request(
|
|
96
|
+
method,
|
|
97
|
+
path,
|
|
98
|
+
params=params,
|
|
99
|
+
body=body,
|
|
100
|
+
idempotency_key=idempotency_key,
|
|
101
|
+
)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BluetickError(Exception):
|
|
9
|
+
"""Base class for all Blueticks client errors."""
|
|
10
|
+
|
|
11
|
+
status_code: int | None
|
|
12
|
+
code: str | None
|
|
13
|
+
message: str
|
|
14
|
+
request_id: str | None
|
|
15
|
+
response: httpx.Response | None
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
*,
|
|
20
|
+
status_code: int | None = None,
|
|
21
|
+
code: str | None = None,
|
|
22
|
+
message: str = "",
|
|
23
|
+
request_id: str | None = None,
|
|
24
|
+
response: httpx.Response | None = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
self.status_code = status_code
|
|
27
|
+
self.code = code
|
|
28
|
+
self.message = message
|
|
29
|
+
self.request_id = request_id
|
|
30
|
+
self.response = response
|
|
31
|
+
super().__init__(str(self))
|
|
32
|
+
|
|
33
|
+
def __str__(self) -> str:
|
|
34
|
+
return f"{self.status_code} {self.code}: {self.message} (request_id={self.request_id})"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AuthenticationError(BluetickError):
|
|
38
|
+
"""401 — missing or invalid API key."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PermissionDeniedError(BluetickError):
|
|
42
|
+
"""403 — the key lacks the required scope or workspace access."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class NotFoundError(BluetickError):
|
|
46
|
+
"""404 — the resource does not exist."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class BadRequestError(BluetickError):
|
|
50
|
+
"""400 / 422 — the request was malformed or failed validation."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class RateLimitError(BluetickError):
|
|
54
|
+
"""429 — the client is being throttled."""
|
|
55
|
+
|
|
56
|
+
retry_after: float | None
|
|
57
|
+
|
|
58
|
+
def __init__(self, *, retry_after: float | None = None, **kwargs: Any) -> None:
|
|
59
|
+
super().__init__(**kwargs)
|
|
60
|
+
self.retry_after = retry_after
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class APIError(BluetickError):
|
|
64
|
+
"""5xx or otherwise-unparseable error response from the API."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class APIConnectionError(BluetickError):
|
|
68
|
+
"""Network failure: DNS, TLS, timeout, connection reset."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
_STATUS_TO_CLASS: dict[int, type[BluetickError]] = {
|
|
72
|
+
400: BadRequestError,
|
|
73
|
+
401: AuthenticationError,
|
|
74
|
+
403: PermissionDeniedError,
|
|
75
|
+
404: NotFoundError,
|
|
76
|
+
422: BadRequestError,
|
|
77
|
+
429: RateLimitError,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _class_for_status(status: int) -> type[BluetickError]:
|
|
82
|
+
return _STATUS_TO_CLASS.get(status, APIError)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def from_envelope(
|
|
86
|
+
*,
|
|
87
|
+
status_code: int,
|
|
88
|
+
body: Any,
|
|
89
|
+
response: httpx.Response | None,
|
|
90
|
+
retry_after: float | None = None,
|
|
91
|
+
) -> BluetickError:
|
|
92
|
+
"""Map a non-2xx response body to a typed Blueticks exception."""
|
|
93
|
+
cls = _class_for_status(status_code)
|
|
94
|
+
|
|
95
|
+
error = body.get("error") if isinstance(body, dict) else None
|
|
96
|
+
if isinstance(error, dict) and isinstance(error.get("code"), str):
|
|
97
|
+
kwargs: dict[str, Any] = {
|
|
98
|
+
"status_code": status_code,
|
|
99
|
+
"code": error.get("code"),
|
|
100
|
+
"message": error.get("message", ""),
|
|
101
|
+
"request_id": error.get("request_id"),
|
|
102
|
+
"response": response,
|
|
103
|
+
}
|
|
104
|
+
if cls is RateLimitError:
|
|
105
|
+
kwargs["retry_after"] = retry_after
|
|
106
|
+
return cls(**kwargs)
|
|
107
|
+
|
|
108
|
+
# Fallback: unknown shape. Render the body as a truncated string.
|
|
109
|
+
raw = body if isinstance(body, str) else repr(body)
|
|
110
|
+
truncated = raw[:200] + ("..." if len(raw) > 200 else "")
|
|
111
|
+
kwargs = {
|
|
112
|
+
"status_code": status_code,
|
|
113
|
+
"code": None,
|
|
114
|
+
"message": truncated,
|
|
115
|
+
"request_id": None,
|
|
116
|
+
"response": response,
|
|
117
|
+
}
|
|
118
|
+
if cls is RateLimitError:
|
|
119
|
+
kwargs["retry_after"] = retry_after
|
|
120
|
+
return cls(**kwargs)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import random
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from blueticks._errors import (
|
|
11
|
+
APIConnectionError,
|
|
12
|
+
from_envelope,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
_RETRIABLE_STATUS = {429, 502, 503, 504}
|
|
16
|
+
_IDEMPOTENT_METHODS = {"GET", "HEAD", "OPTIONS", "DELETE", "PATCH", "PUT"}
|
|
17
|
+
# Note: PATCH is not strictly idempotent per RFC 5789 but most REST APIs treat
|
|
18
|
+
# partial updates as idempotent in practice. Matches Stripe/OpenAI convention.
|
|
19
|
+
_BACKOFF_BASE_SECONDS = 0.5
|
|
20
|
+
_BACKOFF_CAP_SECONDS = 8.0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Transport:
|
|
24
|
+
"""HTTP transport for the Blueticks API. Wraps httpx.Client with retries."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
*,
|
|
29
|
+
api_key: str,
|
|
30
|
+
base_url: str,
|
|
31
|
+
timeout: float,
|
|
32
|
+
max_retries: int,
|
|
33
|
+
user_agent_suffix: str | None,
|
|
34
|
+
version: str,
|
|
35
|
+
http_transport: httpx.BaseTransport | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
self._api_key = api_key
|
|
38
|
+
self._base_url = base_url.rstrip("/")
|
|
39
|
+
self._max_retries = max_retries
|
|
40
|
+
self._version = version
|
|
41
|
+
self._user_agent = f"blueticks-python/{version}"
|
|
42
|
+
if user_agent_suffix:
|
|
43
|
+
self._user_agent = f"{self._user_agent} {user_agent_suffix}"
|
|
44
|
+
self._client = httpx.Client(
|
|
45
|
+
base_url=self._base_url,
|
|
46
|
+
timeout=timeout,
|
|
47
|
+
transport=http_transport,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def close(self) -> None:
|
|
51
|
+
self._client.close()
|
|
52
|
+
|
|
53
|
+
def request(
|
|
54
|
+
self,
|
|
55
|
+
method: str,
|
|
56
|
+
path: str,
|
|
57
|
+
*,
|
|
58
|
+
params: dict[str, Any] | None,
|
|
59
|
+
body: dict[str, Any] | None,
|
|
60
|
+
idempotency_key: str | None = None,
|
|
61
|
+
) -> Any:
|
|
62
|
+
method = method.upper()
|
|
63
|
+
headers: dict[str, str] = {
|
|
64
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
65
|
+
"User-Agent": self._user_agent,
|
|
66
|
+
"Accept": "application/json",
|
|
67
|
+
}
|
|
68
|
+
if body is not None:
|
|
69
|
+
headers["Content-Type"] = "application/json"
|
|
70
|
+
if idempotency_key:
|
|
71
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
72
|
+
|
|
73
|
+
is_idempotent = method in _IDEMPOTENT_METHODS or idempotency_key is not None
|
|
74
|
+
|
|
75
|
+
attempt = 0
|
|
76
|
+
while True:
|
|
77
|
+
try:
|
|
78
|
+
response = self._client.request(
|
|
79
|
+
method,
|
|
80
|
+
path,
|
|
81
|
+
params=params,
|
|
82
|
+
content=json.dumps(body).encode() if body is not None else None,
|
|
83
|
+
headers=headers,
|
|
84
|
+
)
|
|
85
|
+
except httpx.TransportError as exc:
|
|
86
|
+
if attempt < self._max_retries:
|
|
87
|
+
self._sleep_backoff(attempt, retry_after=None)
|
|
88
|
+
attempt += 1
|
|
89
|
+
continue
|
|
90
|
+
raise APIConnectionError(
|
|
91
|
+
status_code=None,
|
|
92
|
+
code=None,
|
|
93
|
+
message=f"connection error: {exc}",
|
|
94
|
+
request_id=None,
|
|
95
|
+
response=None,
|
|
96
|
+
) from exc
|
|
97
|
+
|
|
98
|
+
if 200 <= response.status_code < 300:
|
|
99
|
+
if not response.content:
|
|
100
|
+
return None
|
|
101
|
+
return response.json()
|
|
102
|
+
|
|
103
|
+
parsed_body: Any
|
|
104
|
+
try:
|
|
105
|
+
parsed_body = response.json()
|
|
106
|
+
except (json.JSONDecodeError, ValueError):
|
|
107
|
+
parsed_body = response.text
|
|
108
|
+
|
|
109
|
+
retry_after = self._parse_retry_after(response) if response.status_code == 429 else None
|
|
110
|
+
|
|
111
|
+
retriable_status = response.status_code in _RETRIABLE_STATUS
|
|
112
|
+
if retriable_status and is_idempotent and attempt < self._max_retries:
|
|
113
|
+
self._sleep_backoff(attempt, retry_after=retry_after)
|
|
114
|
+
attempt += 1
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
raise from_envelope(
|
|
118
|
+
status_code=response.status_code,
|
|
119
|
+
body=parsed_body,
|
|
120
|
+
response=response,
|
|
121
|
+
retry_after=retry_after,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
def _parse_retry_after(response: httpx.Response) -> float | None:
|
|
126
|
+
raw = response.headers.get("retry-after")
|
|
127
|
+
if raw is None:
|
|
128
|
+
return None
|
|
129
|
+
try:
|
|
130
|
+
return float(raw)
|
|
131
|
+
except ValueError:
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
@staticmethod
|
|
135
|
+
def _sleep_backoff(attempt: int, *, retry_after: float | None) -> None:
|
|
136
|
+
if retry_after is not None:
|
|
137
|
+
time.sleep(retry_after)
|
|
138
|
+
return
|
|
139
|
+
backoff = min(_BACKOFF_BASE_SECONDS * (2**attempt), _BACKOFF_CAP_SECONDS)
|
|
140
|
+
jitter = random.uniform(0, _BACKOFF_BASE_SECONDS)
|
|
141
|
+
time.sleep(backoff + jitter)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
|
File without changes
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from blueticks._base_resource import BaseResource
|
|
4
|
+
from blueticks.types.account import Account
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AccountResource(BaseResource):
|
|
8
|
+
def retrieve(self) -> Account:
|
|
9
|
+
"""Retrieve the authenticated account.
|
|
10
|
+
|
|
11
|
+
Returns the account associated with the API key used for this request.
|
|
12
|
+
"""
|
|
13
|
+
data = self._client._request("GET", "/v1/account")
|
|
14
|
+
return Account.model_validate(data)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from blueticks._base_resource import BaseResource
|
|
4
|
+
from blueticks.types.ping import Ping
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class PingResource(BaseResource):
|
|
8
|
+
def retrieve(self) -> Ping:
|
|
9
|
+
"""Health check.
|
|
10
|
+
|
|
11
|
+
Returns basic info about the authenticated API key.
|
|
12
|
+
"""
|
|
13
|
+
data = self._client._request("GET", "/v1/ping")
|
|
14
|
+
return Ping.model_validate(data)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Account(BaseModel):
|
|
10
|
+
"""Response from GET /v1/account."""
|
|
11
|
+
|
|
12
|
+
model_config = ConfigDict(extra="ignore")
|
|
13
|
+
|
|
14
|
+
id: str
|
|
15
|
+
name: str
|
|
16
|
+
timezone: Optional[str] = None # noqa: UP045
|
|
17
|
+
created_at: datetime
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: blueticks
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official Python client for the Blueticks API.
|
|
5
|
+
Author-email: Blueticks <support@blueticks.co>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 serenix-com
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://blueticks.co
|
|
29
|
+
Project-URL: Documentation, https://docs.blueticks.co
|
|
30
|
+
Project-URL: Source, https://github.com/serenix-com/blueticks-python
|
|
31
|
+
Project-URL: Changelog, https://github.com/serenix-com/blueticks-python/blob/main/CHANGELOG.md
|
|
32
|
+
Keywords: blueticks,whatsapp,api,sdk
|
|
33
|
+
Classifier: Development Status :: 4 - Beta
|
|
34
|
+
Classifier: Intended Audience :: Developers
|
|
35
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
36
|
+
Classifier: Programming Language :: Python :: 3
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
41
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
42
|
+
Requires-Python: >=3.9
|
|
43
|
+
Description-Content-Type: text/markdown
|
|
44
|
+
License-File: LICENSE
|
|
45
|
+
Requires-Dist: httpx<1.0,>=0.27
|
|
46
|
+
Requires-Dist: pydantic<3.0,>=2.6
|
|
47
|
+
Provides-Extra: dev
|
|
48
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
49
|
+
Requires-Dist: pytest-httpx>=0.30; extra == "dev"
|
|
50
|
+
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
51
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
52
|
+
Dynamic: license-file
|
|
53
|
+
|
|
54
|
+
# blueticks — Python client for the Blueticks API
|
|
55
|
+
|
|
56
|
+
Official Python SDK for [Blueticks](https://blueticks.co).
|
|
57
|
+
|
|
58
|
+
## Install
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pip install blueticks
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Quickstart
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from blueticks import Blueticks
|
|
68
|
+
|
|
69
|
+
client = Blueticks(api_key="bt_live_...")
|
|
70
|
+
ping = client.ping()
|
|
71
|
+
account = client.account.retrieve()
|
|
72
|
+
print(account.name)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
See https://docs.blueticks.co for full documentation.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/blueticks/__init__.py
|
|
5
|
+
src/blueticks/_base_resource.py
|
|
6
|
+
src/blueticks/_client.py
|
|
7
|
+
src/blueticks/_errors.py
|
|
8
|
+
src/blueticks/_transport.py
|
|
9
|
+
src/blueticks/_version.py
|
|
10
|
+
src/blueticks/py.typed
|
|
11
|
+
src/blueticks.egg-info/PKG-INFO
|
|
12
|
+
src/blueticks.egg-info/SOURCES.txt
|
|
13
|
+
src/blueticks.egg-info/dependency_links.txt
|
|
14
|
+
src/blueticks.egg-info/requires.txt
|
|
15
|
+
src/blueticks.egg-info/top_level.txt
|
|
16
|
+
src/blueticks/resources/__init__.py
|
|
17
|
+
src/blueticks/resources/account.py
|
|
18
|
+
src/blueticks/resources/ping.py
|
|
19
|
+
src/blueticks/types/__init__.py
|
|
20
|
+
src/blueticks/types/account.py
|
|
21
|
+
src/blueticks/types/ping.py
|
|
22
|
+
tests/test_account.py
|
|
23
|
+
tests/test_client.py
|
|
24
|
+
tests/test_errors.py
|
|
25
|
+
tests/test_ping.py
|
|
26
|
+
tests/test_transport.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
blueticks
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
import pytest
|
|
8
|
+
from pydantic import ValidationError
|
|
9
|
+
|
|
10
|
+
from blueticks import AuthenticationError
|
|
11
|
+
from blueticks.types.account import Account
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_account_retrieve_returns_typed_model(mock_client) -> None:
|
|
15
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
16
|
+
assert request.method == "GET"
|
|
17
|
+
assert request.url.path == "/v1/account"
|
|
18
|
+
return httpx.Response(
|
|
19
|
+
200,
|
|
20
|
+
content=json.dumps(
|
|
21
|
+
{
|
|
22
|
+
"id": "acc_1",
|
|
23
|
+
"name": "Acme",
|
|
24
|
+
"timezone": "America/New_York",
|
|
25
|
+
"created_at": "2026-04-22T10:00:00Z",
|
|
26
|
+
}
|
|
27
|
+
).encode(),
|
|
28
|
+
headers={"content-type": "application/json"},
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
with mock_client(handler) as client:
|
|
32
|
+
result = client.account.retrieve()
|
|
33
|
+
assert isinstance(result, Account)
|
|
34
|
+
assert result.id == "acc_1"
|
|
35
|
+
assert result.name == "Acme"
|
|
36
|
+
assert result.timezone == "America/New_York"
|
|
37
|
+
assert isinstance(result.created_at, datetime)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_account_retrieve_accepts_null_timezone(mock_client) -> None:
|
|
41
|
+
def handler(_request: httpx.Request) -> httpx.Response:
|
|
42
|
+
return httpx.Response(
|
|
43
|
+
200,
|
|
44
|
+
content=json.dumps(
|
|
45
|
+
{
|
|
46
|
+
"id": "acc_2",
|
|
47
|
+
"name": "Nobody",
|
|
48
|
+
"timezone": None,
|
|
49
|
+
"created_at": "2026-01-01T00:00:00Z",
|
|
50
|
+
}
|
|
51
|
+
).encode(),
|
|
52
|
+
headers={"content-type": "application/json"},
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
with mock_client(handler) as client:
|
|
56
|
+
result = client.account.retrieve()
|
|
57
|
+
assert result.timezone is None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_account_retrieve_propagates_authentication_error(mock_client) -> None:
|
|
61
|
+
def handler(_request: httpx.Request) -> httpx.Response:
|
|
62
|
+
body = {
|
|
63
|
+
"error": {
|
|
64
|
+
"code": "authentication_required",
|
|
65
|
+
"message": "bad key",
|
|
66
|
+
"request_id": "req_a",
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return httpx.Response(
|
|
70
|
+
401,
|
|
71
|
+
content=json.dumps(body).encode(),
|
|
72
|
+
headers={"content-type": "application/json"},
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
with mock_client(handler) as client:
|
|
76
|
+
with pytest.raises(AuthenticationError) as info:
|
|
77
|
+
client.account.retrieve()
|
|
78
|
+
assert info.value.code == "authentication_required"
|
|
79
|
+
assert info.value.message == "bad key"
|
|
80
|
+
assert info.value.request_id == "req_a"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_account_retrieve_missing_required_field_raises_validation_error(mock_client) -> None:
|
|
84
|
+
def handler(_request: httpx.Request) -> httpx.Response:
|
|
85
|
+
return httpx.Response(200, content=b"{}", headers={"content-type": "application/json"})
|
|
86
|
+
|
|
87
|
+
with mock_client(handler) as client:
|
|
88
|
+
with pytest.raises(ValidationError):
|
|
89
|
+
client.account.retrieve()
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from blueticks import Blueticks
|
|
6
|
+
from blueticks._errors import BluetickError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_constructor_accepts_api_key() -> None:
|
|
10
|
+
c = Blueticks(api_key="bt_live_x")
|
|
11
|
+
assert c._api_key == "bt_live_x"
|
|
12
|
+
c.close()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_constructor_falls_back_to_env_var(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
16
|
+
monkeypatch.setenv("BLUETICKS_API_KEY", "bt_test_env")
|
|
17
|
+
c = Blueticks()
|
|
18
|
+
assert c._api_key == "bt_test_env"
|
|
19
|
+
c.close()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_constructor_raises_when_no_key(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
23
|
+
monkeypatch.delenv("BLUETICKS_API_KEY", raising=False)
|
|
24
|
+
with pytest.raises(BluetickError) as info:
|
|
25
|
+
Blueticks()
|
|
26
|
+
assert "api_key" in str(info.value).lower() or "BLUETICKS_API_KEY" in str(info.value)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_base_url_env_var_overrides_default(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
30
|
+
monkeypatch.setenv("BLUETICKS_BASE_URL", "https://staging.example.test")
|
|
31
|
+
c = Blueticks(api_key="k")
|
|
32
|
+
assert c._base_url == "https://staging.example.test"
|
|
33
|
+
c.close()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_explicit_base_url_overrides_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
37
|
+
monkeypatch.setenv("BLUETICKS_BASE_URL", "https://env.example.test")
|
|
38
|
+
c = Blueticks(api_key="k", base_url="https://explicit.example.test")
|
|
39
|
+
assert c._base_url == "https://explicit.example.test"
|
|
40
|
+
c.close()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_context_manager_closes_transport() -> None:
|
|
44
|
+
with Blueticks(api_key="k") as c:
|
|
45
|
+
transport = c._transport
|
|
46
|
+
assert transport._client.is_closed
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_default_base_url() -> None:
|
|
50
|
+
c = Blueticks(api_key="k")
|
|
51
|
+
assert c._base_url == "https://api.blueticks.co"
|
|
52
|
+
c.close()
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from blueticks._errors import (
|
|
6
|
+
APIConnectionError,
|
|
7
|
+
APIError,
|
|
8
|
+
AuthenticationError,
|
|
9
|
+
BadRequestError,
|
|
10
|
+
BluetickError,
|
|
11
|
+
NotFoundError,
|
|
12
|
+
PermissionDeniedError,
|
|
13
|
+
RateLimitError,
|
|
14
|
+
from_envelope,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_base_class_is_exception() -> None:
|
|
19
|
+
assert issubclass(BluetickError, Exception)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_all_subclasses_inherit_bluetick_error() -> None:
|
|
23
|
+
for cls in [
|
|
24
|
+
AuthenticationError,
|
|
25
|
+
PermissionDeniedError,
|
|
26
|
+
NotFoundError,
|
|
27
|
+
BadRequestError,
|
|
28
|
+
RateLimitError,
|
|
29
|
+
APIError,
|
|
30
|
+
APIConnectionError,
|
|
31
|
+
]:
|
|
32
|
+
assert issubclass(cls, BluetickError)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_str_renders_status_code_and_request_id() -> None:
|
|
36
|
+
err = BluetickError(
|
|
37
|
+
status_code=401,
|
|
38
|
+
code="authentication_required",
|
|
39
|
+
message="bad key",
|
|
40
|
+
request_id="req_123",
|
|
41
|
+
)
|
|
42
|
+
assert str(err) == "401 authentication_required: bad key (request_id=req_123)"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_str_handles_missing_request_id() -> None:
|
|
46
|
+
err = BluetickError(status_code=500, code="internal_error", message="boom", request_id=None)
|
|
47
|
+
assert "request_id=None" in str(err)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@pytest.mark.parametrize(
|
|
51
|
+
"status,code,expected_cls",
|
|
52
|
+
[
|
|
53
|
+
(401, "authentication_required", AuthenticationError),
|
|
54
|
+
(403, "permission_denied", PermissionDeniedError),
|
|
55
|
+
(404, "not_found", NotFoundError),
|
|
56
|
+
(400, "invalid_request", BadRequestError),
|
|
57
|
+
(422, "invalid_request", BadRequestError),
|
|
58
|
+
(429, "rate_limited", RateLimitError),
|
|
59
|
+
(500, "internal_error", APIError),
|
|
60
|
+
(503, "internal_error", APIError),
|
|
61
|
+
],
|
|
62
|
+
)
|
|
63
|
+
def test_from_envelope_maps_status_to_typed_exception(
|
|
64
|
+
status: int, code: str, expected_cls: type[BluetickError]
|
|
65
|
+
) -> None:
|
|
66
|
+
err = from_envelope(
|
|
67
|
+
status_code=status,
|
|
68
|
+
body={"error": {"code": code, "message": "m", "request_id": "r"}},
|
|
69
|
+
response=None,
|
|
70
|
+
)
|
|
71
|
+
assert isinstance(err, expected_cls)
|
|
72
|
+
assert err.status_code == status
|
|
73
|
+
assert err.code == code
|
|
74
|
+
assert err.message == "m"
|
|
75
|
+
assert err.request_id == "r"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_from_envelope_falls_back_when_body_has_no_error_key() -> None:
|
|
79
|
+
err = from_envelope(status_code=502, body={"weird": "shape"}, response=None)
|
|
80
|
+
assert isinstance(err, APIError)
|
|
81
|
+
assert err.code is None
|
|
82
|
+
# raw body, truncated
|
|
83
|
+
assert "weird" in err.message
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_from_envelope_truncates_long_bodies() -> None:
|
|
87
|
+
big = "x" * 500
|
|
88
|
+
err = from_envelope(status_code=500, body=big, response=None)
|
|
89
|
+
assert err.message.endswith("...")
|
|
90
|
+
assert len(err.message) == 203 # 200 chars + 3-char ellipsis
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_rate_limit_error_carries_retry_after() -> None:
|
|
94
|
+
err = RateLimitError(
|
|
95
|
+
status_code=429,
|
|
96
|
+
code="rate_limited",
|
|
97
|
+
message="slow down",
|
|
98
|
+
request_id=None,
|
|
99
|
+
retry_after=30.0,
|
|
100
|
+
)
|
|
101
|
+
assert err.retry_after == 30.0
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import pytest
|
|
7
|
+
from pydantic import ValidationError
|
|
8
|
+
|
|
9
|
+
from blueticks import AuthenticationError
|
|
10
|
+
from blueticks.types.ping import Ping
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_ping_retrieve_returns_typed_model(mock_client) -> None:
|
|
14
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
15
|
+
assert request.method == "GET"
|
|
16
|
+
assert request.url.path == "/v1/ping"
|
|
17
|
+
return httpx.Response(
|
|
18
|
+
200,
|
|
19
|
+
content=json.dumps(
|
|
20
|
+
{
|
|
21
|
+
"account_id": "acc_abc",
|
|
22
|
+
"key_prefix": "xy12ab34",
|
|
23
|
+
"scopes": ["messages:read"],
|
|
24
|
+
}
|
|
25
|
+
).encode(),
|
|
26
|
+
headers={"content-type": "application/json"},
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
with mock_client(handler) as client:
|
|
30
|
+
result = client.ping()
|
|
31
|
+
assert isinstance(result, Ping)
|
|
32
|
+
assert result.account_id == "acc_abc"
|
|
33
|
+
assert result.key_prefix == "xy12ab34"
|
|
34
|
+
assert result.scopes == ["messages:read"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_ping_propagates_authentication_error(mock_client) -> None:
|
|
38
|
+
def handler(_request: httpx.Request) -> httpx.Response:
|
|
39
|
+
body = {
|
|
40
|
+
"error": {
|
|
41
|
+
"code": "authentication_required",
|
|
42
|
+
"message": "bad key",
|
|
43
|
+
"request_id": "req_1",
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return httpx.Response(
|
|
47
|
+
401,
|
|
48
|
+
content=json.dumps(body).encode(),
|
|
49
|
+
headers={"content-type": "application/json"},
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
with mock_client(handler) as client:
|
|
53
|
+
with pytest.raises(AuthenticationError) as info:
|
|
54
|
+
client.ping()
|
|
55
|
+
assert info.value.code == "authentication_required"
|
|
56
|
+
assert info.value.message == "bad key"
|
|
57
|
+
assert info.value.request_id == "req_1"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_ping_missing_required_field_raises_validation_error(mock_client) -> None:
|
|
61
|
+
def handler(_request: httpx.Request) -> httpx.Response:
|
|
62
|
+
return httpx.Response(200, content=b"{}", headers={"content-type": "application/json"})
|
|
63
|
+
|
|
64
|
+
with mock_client(handler) as client:
|
|
65
|
+
with pytest.raises(ValidationError):
|
|
66
|
+
client.ping()
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from blueticks._errors import (
|
|
10
|
+
APIConnectionError,
|
|
11
|
+
APIError,
|
|
12
|
+
AuthenticationError,
|
|
13
|
+
RateLimitError,
|
|
14
|
+
)
|
|
15
|
+
from blueticks._transport import Transport
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _envelope(code: str, message: str = "x", request_id: str = "req_1") -> bytes:
|
|
19
|
+
body = {"error": {"code": code, "message": message, "request_id": request_id}}
|
|
20
|
+
return json.dumps(body).encode()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _ok(body: Any) -> bytes:
|
|
24
|
+
return json.dumps(body).encode()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _mock(handler):
|
|
28
|
+
return httpx.MockTransport(handler)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _make_transport(mock: httpx.MockTransport, *, max_retries: int = 2) -> Transport:
|
|
32
|
+
return Transport(
|
|
33
|
+
api_key="bt_test_abc",
|
|
34
|
+
base_url="https://example.test",
|
|
35
|
+
timeout=5.0,
|
|
36
|
+
max_retries=max_retries,
|
|
37
|
+
user_agent_suffix=None,
|
|
38
|
+
version="1.0.0",
|
|
39
|
+
http_transport=mock,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_get_returns_parsed_json_on_200() -> None:
|
|
44
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
45
|
+
assert request.headers["authorization"] == "Bearer bt_test_abc"
|
|
46
|
+
assert "blueticks-python/1.0.0" in request.headers["user-agent"]
|
|
47
|
+
return httpx.Response(200, content=_ok({"hello": "world"}))
|
|
48
|
+
|
|
49
|
+
transport = _make_transport(_mock(handler))
|
|
50
|
+
result = transport.request("GET", "/v1/ping", params=None, body=None)
|
|
51
|
+
assert result == {"hello": "world"}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_401_raises_authentication_error() -> None:
|
|
55
|
+
def handler(_request: httpx.Request) -> httpx.Response:
|
|
56
|
+
return httpx.Response(401, content=_envelope("authentication_required", "bad"))
|
|
57
|
+
|
|
58
|
+
transport = _make_transport(_mock(handler))
|
|
59
|
+
with pytest.raises(AuthenticationError) as info:
|
|
60
|
+
transport.request("GET", "/v1/ping", params=None, body=None)
|
|
61
|
+
assert info.value.code == "authentication_required"
|
|
62
|
+
assert info.value.message == "bad"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_non_json_body_still_raises_api_error() -> None:
|
|
66
|
+
def handler(_request: httpx.Request) -> httpx.Response:
|
|
67
|
+
return httpx.Response(502, content=b"<html>bad gateway</html>")
|
|
68
|
+
|
|
69
|
+
transport = _make_transport(_mock(handler))
|
|
70
|
+
with pytest.raises(APIError) as info:
|
|
71
|
+
transport.request("GET", "/v1/ping", params=None, body=None)
|
|
72
|
+
assert info.value.status_code == 502
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_429_with_retry_after_retries_then_succeeds() -> None:
|
|
76
|
+
calls = {"n": 0}
|
|
77
|
+
|
|
78
|
+
def handler(_request: httpx.Request) -> httpx.Response:
|
|
79
|
+
calls["n"] += 1
|
|
80
|
+
if calls["n"] == 1:
|
|
81
|
+
return httpx.Response(
|
|
82
|
+
429,
|
|
83
|
+
headers={"retry-after": "0"},
|
|
84
|
+
content=_envelope("rate_limited", "slow"),
|
|
85
|
+
)
|
|
86
|
+
return httpx.Response(200, content=_ok({"ok": True}))
|
|
87
|
+
|
|
88
|
+
transport = _make_transport(_mock(handler), max_retries=3)
|
|
89
|
+
result = transport.request("GET", "/v1/ping", params=None, body=None)
|
|
90
|
+
assert result == {"ok": True}
|
|
91
|
+
assert calls["n"] == 2
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_429_exhausts_retries_and_raises_rate_limit() -> None:
|
|
95
|
+
def handler(_request: httpx.Request) -> httpx.Response:
|
|
96
|
+
return httpx.Response(429, headers={"retry-after": "0"}, content=_envelope("rate_limited"))
|
|
97
|
+
|
|
98
|
+
transport = _make_transport(_mock(handler), max_retries=1)
|
|
99
|
+
with pytest.raises(RateLimitError) as info:
|
|
100
|
+
transport.request("GET", "/v1/ping", params=None, body=None)
|
|
101
|
+
assert info.value.retry_after == 0.0
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_503_retries_then_succeeds() -> None:
|
|
105
|
+
calls = {"n": 0}
|
|
106
|
+
|
|
107
|
+
def handler(_request: httpx.Request) -> httpx.Response:
|
|
108
|
+
calls["n"] += 1
|
|
109
|
+
if calls["n"] == 1:
|
|
110
|
+
return httpx.Response(503, content=_envelope("internal_error"))
|
|
111
|
+
return httpx.Response(200, content=_ok({"ok": True}))
|
|
112
|
+
|
|
113
|
+
transport = _make_transport(_mock(handler), max_retries=2)
|
|
114
|
+
result = transport.request("GET", "/v1/ping", params=None, body=None)
|
|
115
|
+
assert result == {"ok": True}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_post_does_not_retry_on_5xx_without_idempotency_key() -> None:
|
|
119
|
+
calls = {"n": 0}
|
|
120
|
+
|
|
121
|
+
def handler(_request: httpx.Request) -> httpx.Response:
|
|
122
|
+
calls["n"] += 1
|
|
123
|
+
return httpx.Response(503, content=_envelope("internal_error"))
|
|
124
|
+
|
|
125
|
+
transport = _make_transport(_mock(handler), max_retries=3)
|
|
126
|
+
with pytest.raises(APIError):
|
|
127
|
+
transport.request("POST", "/v1/messages", params=None, body={"x": 1})
|
|
128
|
+
assert calls["n"] == 1
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_connection_error_retries() -> None:
|
|
132
|
+
calls = {"n": 0}
|
|
133
|
+
|
|
134
|
+
def handler(_request: httpx.Request) -> httpx.Response:
|
|
135
|
+
calls["n"] += 1
|
|
136
|
+
if calls["n"] == 1:
|
|
137
|
+
raise httpx.ConnectError("dns fail")
|
|
138
|
+
return httpx.Response(200, content=_ok({"ok": True}))
|
|
139
|
+
|
|
140
|
+
transport = _make_transport(_mock(handler), max_retries=2)
|
|
141
|
+
result = transport.request("GET", "/v1/ping", params=None, body=None)
|
|
142
|
+
assert result == {"ok": True}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_connection_error_exhausted_raises_api_connection_error() -> None:
|
|
146
|
+
def handler(_request: httpx.Request) -> httpx.Response:
|
|
147
|
+
raise httpx.ConnectError("dns fail")
|
|
148
|
+
|
|
149
|
+
transport = _make_transport(_mock(handler), max_retries=1)
|
|
150
|
+
with pytest.raises(APIConnectionError):
|
|
151
|
+
transport.request("GET", "/v1/ping", params=None, body=None)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_user_agent_includes_suffix() -> None:
|
|
155
|
+
captured: dict[str, str] = {}
|
|
156
|
+
|
|
157
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
158
|
+
captured["ua"] = request.headers["user-agent"]
|
|
159
|
+
return httpx.Response(200, content=_ok({}))
|
|
160
|
+
|
|
161
|
+
t = Transport(
|
|
162
|
+
api_key="k",
|
|
163
|
+
base_url="https://example.test",
|
|
164
|
+
timeout=5.0,
|
|
165
|
+
max_retries=0,
|
|
166
|
+
user_agent_suffix="myapp/1.2",
|
|
167
|
+
version="1.0.0",
|
|
168
|
+
http_transport=_mock(handler),
|
|
169
|
+
)
|
|
170
|
+
t.request("GET", "/v1/ping", params=None, body=None)
|
|
171
|
+
assert captured["ua"] == "blueticks-python/1.0.0 myapp/1.2"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_query_params_are_sent() -> None:
|
|
175
|
+
captured: dict[str, Any] = {}
|
|
176
|
+
|
|
177
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
178
|
+
captured["url"] = str(request.url)
|
|
179
|
+
return httpx.Response(200, content=_ok({}))
|
|
180
|
+
|
|
181
|
+
transport = _make_transport(_mock(handler))
|
|
182
|
+
transport.request("GET", "/v1/messages", params={"limit": 10}, body=None)
|
|
183
|
+
assert "limit=10" in captured["url"]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_body_is_json_encoded() -> None:
|
|
187
|
+
captured: dict[str, Any] = {}
|
|
188
|
+
|
|
189
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
190
|
+
captured["body"] = request.content
|
|
191
|
+
return httpx.Response(200, content=_ok({}))
|
|
192
|
+
|
|
193
|
+
transport = _make_transport(_mock(handler))
|
|
194
|
+
transport.request("POST", "/v1/messages", params=None, body={"text": "hi"})
|
|
195
|
+
assert json.loads(captured["body"]) == {"text": "hi"}
|