pyyapper 1.0.0__py3-none-any.whl
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.
- pyyapper/__init__.py +48 -0
- pyyapper/_version.py +1 -0
- pyyapper/bot.py +769 -0
- pyyapper/buttons.py +117 -0
- pyyapper/embeds.py +124 -0
- pyyapper/errors.py +90 -0
- pyyapper/http.py +200 -0
- pyyapper/models.py +206 -0
- pyyapper/py.typed +0 -0
- pyyapper-1.0.0.dist-info/METADATA +108 -0
- pyyapper-1.0.0.dist-info/RECORD +14 -0
- pyyapper-1.0.0.dist-info/WHEEL +5 -0
- pyyapper-1.0.0.dist-info/licenses/LICENSE +21 -0
- pyyapper-1.0.0.dist-info/top_level.txt +1 -0
pyyapper/buttons.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Button and Row builders for PyYapper.
|
|
2
|
+
|
|
3
|
+
Buttons are attached to messages via ``components`` (a list of :class:`Row`).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Button:
|
|
12
|
+
"""A clickable button.
|
|
13
|
+
|
|
14
|
+
Use the class-method shortcuts for the common cases::
|
|
15
|
+
|
|
16
|
+
Button.primary("Go", "go")
|
|
17
|
+
Button.link("Rules", "https://example.com")
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
STYLES = ("primary", "secondary", "success", "danger", "link")
|
|
21
|
+
|
|
22
|
+
def __init__(self, data: dict[str, Any] | None = None) -> None:
|
|
23
|
+
self.data: dict[str, Any] = {"type": "button", "style": "secondary"}
|
|
24
|
+
if data:
|
|
25
|
+
self.data.update(data)
|
|
26
|
+
|
|
27
|
+
# -- shortcuts ---------------------------------------------------------
|
|
28
|
+
@classmethod
|
|
29
|
+
def primary(cls, label: str, custom_id: str) -> "Button":
|
|
30
|
+
return cls({"style": "primary", "label": label, "custom_id": custom_id})
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def secondary(cls, label: str, custom_id: str) -> "Button":
|
|
34
|
+
return cls({"style": "secondary", "label": label, "custom_id": custom_id})
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def success(cls, label: str, custom_id: str) -> "Button":
|
|
38
|
+
return cls({"style": "success", "label": label, "custom_id": custom_id})
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def danger(cls, label: str, custom_id: str) -> "Button":
|
|
42
|
+
return cls({"style": "danger", "label": label, "custom_id": custom_id})
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def link(cls, label: str, url: str) -> "Button":
|
|
46
|
+
return cls({"style": "link", "label": label, "url": url})
|
|
47
|
+
|
|
48
|
+
# -- chainable setters -------------------------------------------------
|
|
49
|
+
def style(self, s: str) -> "Button":
|
|
50
|
+
if s not in self.STYLES:
|
|
51
|
+
raise ValueError(f"Unknown button style: {s!r}")
|
|
52
|
+
self.data["style"] = s
|
|
53
|
+
return self
|
|
54
|
+
|
|
55
|
+
def label(self, text: str) -> "Button":
|
|
56
|
+
self.data["label"] = str(text)
|
|
57
|
+
return self
|
|
58
|
+
|
|
59
|
+
def emoji(self, e: str) -> "Button":
|
|
60
|
+
self.data["emoji"] = str(e)
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
def id(self, custom_id: str) -> "Button": # noqa: A003 - mirrors JS
|
|
64
|
+
self.data["custom_id"] = str(custom_id)
|
|
65
|
+
if "url" in self.data:
|
|
66
|
+
del self.data["url"]
|
|
67
|
+
return self
|
|
68
|
+
|
|
69
|
+
def url(self, u: str) -> "Button": # noqa: A003
|
|
70
|
+
self.data["style"] = "link"
|
|
71
|
+
self.data["url"] = str(u)
|
|
72
|
+
self.data.pop("custom_id", None)
|
|
73
|
+
return self
|
|
74
|
+
|
|
75
|
+
def disabled(self, on: bool = True) -> "Button":
|
|
76
|
+
self.data["disabled"] = bool(on)
|
|
77
|
+
return self
|
|
78
|
+
|
|
79
|
+
def to_dict(self) -> dict[str, Any]:
|
|
80
|
+
return dict(self.data)
|
|
81
|
+
|
|
82
|
+
def toJSON(self) -> dict[str, Any]:
|
|
83
|
+
return self.to_dict()
|
|
84
|
+
|
|
85
|
+
def __repr__(self) -> str: # pragma: no cover
|
|
86
|
+
return f"Button({self.data!r})"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Row:
|
|
90
|
+
"""A row of up to 5 buttons."""
|
|
91
|
+
|
|
92
|
+
def __init__(self, *buttons: Any) -> None:
|
|
93
|
+
self.buttons: list[Any] = []
|
|
94
|
+
for b in buttons:
|
|
95
|
+
self.buttons.extend(b if isinstance(b, list) else [b])
|
|
96
|
+
|
|
97
|
+
def add(self, *buttons: Any) -> "Row":
|
|
98
|
+
for b in buttons:
|
|
99
|
+
self.buttons.extend(b if isinstance(b, list) else [b])
|
|
100
|
+
return self
|
|
101
|
+
|
|
102
|
+
def to_dict(self) -> dict[str, Any]:
|
|
103
|
+
out = []
|
|
104
|
+
for b in self.buttons:
|
|
105
|
+
if hasattr(b, "to_dict"):
|
|
106
|
+
out.append(b.to_dict())
|
|
107
|
+
elif hasattr(b, "toJSON"):
|
|
108
|
+
out.append(b.toJSON())
|
|
109
|
+
else:
|
|
110
|
+
out.append(b)
|
|
111
|
+
return {"type": "row", "components": out}
|
|
112
|
+
|
|
113
|
+
def toJSON(self) -> dict[str, Any]:
|
|
114
|
+
return self.to_dict()
|
|
115
|
+
|
|
116
|
+
def __repr__(self) -> str: # pragma: no cover
|
|
117
|
+
return f"Row({self.buttons!r})"
|
pyyapper/embeds.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Embed (card) builder for PyYapper.
|
|
2
|
+
|
|
3
|
+
Mirrors the JS SDK's ``Embed`` class: every setter returns ``self`` so calls
|
|
4
|
+
chain, and :meth:`to_dict` produces the JSON payload Yapper expects.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
_NAMED_COLORS = {
|
|
13
|
+
"red": 0xED4245,
|
|
14
|
+
"green": 0x57F287,
|
|
15
|
+
"blue": 0x5865F2,
|
|
16
|
+
"yellow": 0xFEE75C,
|
|
17
|
+
"purple": 0x9B59B6,
|
|
18
|
+
"orange": 0xE67E22,
|
|
19
|
+
"grey": 0x95A5A6,
|
|
20
|
+
"gray": 0x95A5A6,
|
|
21
|
+
"blurple": 0x5865F2,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Embed:
|
|
26
|
+
"""A card that can be attached to a message.
|
|
27
|
+
|
|
28
|
+
Example::
|
|
29
|
+
|
|
30
|
+
embed = (
|
|
31
|
+
Embed()
|
|
32
|
+
.title("Match result")
|
|
33
|
+
.description("**Blue** won 3-1.")
|
|
34
|
+
.color("#5865f2")
|
|
35
|
+
.field("MVP", "<@user-id>", inline=True)
|
|
36
|
+
.footer("Season 2")
|
|
37
|
+
)
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, data: dict[str, Any] | None = None) -> None:
|
|
41
|
+
self.data: dict[str, Any] = dict(data) if data else {}
|
|
42
|
+
|
|
43
|
+
# -- chainable setters -------------------------------------------------
|
|
44
|
+
def title(self, text: str) -> "Embed":
|
|
45
|
+
self.data["title"] = str(text)
|
|
46
|
+
return self
|
|
47
|
+
|
|
48
|
+
def description(self, text: str) -> "Embed":
|
|
49
|
+
self.data["description"] = str(text)
|
|
50
|
+
return self
|
|
51
|
+
|
|
52
|
+
def url(self, url: str) -> "Embed":
|
|
53
|
+
self.data["url"] = str(url)
|
|
54
|
+
return self
|
|
55
|
+
|
|
56
|
+
def color(self, color: int | str) -> "Embed":
|
|
57
|
+
if isinstance(color, bool):
|
|
58
|
+
raise TypeError("color must be an int or str")
|
|
59
|
+
if isinstance(color, int):
|
|
60
|
+
self.data["color"] = color
|
|
61
|
+
return self
|
|
62
|
+
name = str(color).strip().lower()
|
|
63
|
+
if name in _NAMED_COLORS:
|
|
64
|
+
self.data["color"] = _NAMED_COLORS[name]
|
|
65
|
+
return self
|
|
66
|
+
hexed = name.lstrip("#").lstrip("0x").lstrip("x")
|
|
67
|
+
# handle 0x prefix already stripped above loosely
|
|
68
|
+
if name.startswith("0x"):
|
|
69
|
+
hexed = name[2:]
|
|
70
|
+
try:
|
|
71
|
+
self.data["color"] = int(hexed, 16)
|
|
72
|
+
except ValueError:
|
|
73
|
+
raise ValueError(f"Unknown color: {color!r}")
|
|
74
|
+
return self
|
|
75
|
+
|
|
76
|
+
def field(self, name: str, value: str, inline: bool = False) -> "Embed":
|
|
77
|
+
self.data.setdefault("fields", []).append(
|
|
78
|
+
{"name": str(name), "value": str(value), "inline": bool(inline)}
|
|
79
|
+
)
|
|
80
|
+
return self
|
|
81
|
+
|
|
82
|
+
def author(self, name: str, *, url: str | None = None, icon: str | None = None) -> "Embed":
|
|
83
|
+
payload: dict[str, Any] = {"name": str(name)}
|
|
84
|
+
if url is not None:
|
|
85
|
+
payload["url"] = str(url)
|
|
86
|
+
if icon is not None:
|
|
87
|
+
payload["icon_url"] = str(icon)
|
|
88
|
+
self.data["author"] = payload
|
|
89
|
+
return self
|
|
90
|
+
|
|
91
|
+
def footer(self, text: str, icon: str | None = None) -> "Embed":
|
|
92
|
+
payload: dict[str, Any] = {"text": str(text)}
|
|
93
|
+
if icon is not None:
|
|
94
|
+
payload["icon_url"] = str(icon)
|
|
95
|
+
self.data["footer"] = payload
|
|
96
|
+
return self
|
|
97
|
+
|
|
98
|
+
def thumbnail(self, url: str) -> "Embed":
|
|
99
|
+
self.data["thumbnail"] = {"url": str(url)}
|
|
100
|
+
return self
|
|
101
|
+
|
|
102
|
+
def image(self, url: str) -> "Embed":
|
|
103
|
+
self.data["image"] = {"url": str(url)}
|
|
104
|
+
return self
|
|
105
|
+
|
|
106
|
+
def timestamp(self, when: datetime | str | None = None) -> "Embed":
|
|
107
|
+
if when is None:
|
|
108
|
+
when = datetime.now(timezone.utc)
|
|
109
|
+
if isinstance(when, datetime):
|
|
110
|
+
if when.tzinfo is None:
|
|
111
|
+
when = when.replace(tzinfo=timezone.utc)
|
|
112
|
+
self.data["timestamp"] = when.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
113
|
+
else:
|
|
114
|
+
self.data["timestamp"] = str(when)
|
|
115
|
+
return self
|
|
116
|
+
|
|
117
|
+
def to_dict(self) -> dict[str, Any]:
|
|
118
|
+
return dict(self.data)
|
|
119
|
+
|
|
120
|
+
def toJSON(self) -> dict[str, Any]: # parity with JS
|
|
121
|
+
return self.to_dict()
|
|
122
|
+
|
|
123
|
+
def __repr__(self) -> str: # pragma: no cover
|
|
124
|
+
return f"Embed({self.data!r})"
|
pyyapper/errors.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Exception types raised by PyYapper.
|
|
2
|
+
|
|
3
|
+
All exceptions derive from :class:`YapperError` so callers can catch the whole
|
|
4
|
+
family with a single ``except``. HTTP failures are represented by
|
|
5
|
+
:class:`HTTPException` (and its subclasses) which carry the response ``status``,
|
|
6
|
+
the parsed ``error`` message and, when the server provides one, ``retry_after``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class YapperError(Exception):
|
|
13
|
+
"""Base class for every error raised by PyYapper."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ConfigurationError(YapperError):
|
|
17
|
+
"""Raised when the bot is configured incorrectly (e.g. a bad token)."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HTTPException(YapperError):
|
|
21
|
+
"""An HTTP request to the Yapper API failed.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
status: HTTP status code returned by the server.
|
|
25
|
+
error: Human readable error message from the server (or a fallback).
|
|
26
|
+
retry_after: Seconds to wait before retrying, when the server sent one.
|
|
27
|
+
method: HTTP method of the failed request.
|
|
28
|
+
path: API path of the failed request.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
status: int,
|
|
34
|
+
error: str = "",
|
|
35
|
+
*,
|
|
36
|
+
method: str = "",
|
|
37
|
+
path: str = "",
|
|
38
|
+
retry_after: float | None = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
self.status = status
|
|
41
|
+
self.error = error or f"HTTP {status}"
|
|
42
|
+
self.method = method
|
|
43
|
+
self.path = path
|
|
44
|
+
self.retry_after = retry_after
|
|
45
|
+
super().__init__(f"{method} {path} failed ({status}): {self.error}".strip())
|
|
46
|
+
|
|
47
|
+
def __repr__(self) -> str: # pragma: no cover - cosmetic
|
|
48
|
+
return f"{type(self).__name__}(status={self.status}, error={self.error!r})"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class BadRequest(HTTPException):
|
|
52
|
+
"""400 - the request body was invalid."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Unauthorized(HTTPException):
|
|
56
|
+
"""401 - missing or wrong token."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Forbidden(HTTPException):
|
|
60
|
+
"""403 - the bot is not in that server, or its roles do not allow it."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class NotFound(HTTPException):
|
|
64
|
+
"""404 - no such thing, or the bot cannot see it."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class RateLimited(HTTPException):
|
|
68
|
+
"""429 - slow down. ``retry_after`` says how many seconds to wait."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ServerError(HTTPException):
|
|
72
|
+
"""5xx - something went wrong on Yapper's side."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# Status code -> exception class mapping used by the HTTP layer.
|
|
76
|
+
STATUS_TO_EXC: dict[int, type[HTTPException]] = {
|
|
77
|
+
400: BadRequest,
|
|
78
|
+
401: Unauthorized,
|
|
79
|
+
403: Forbidden,
|
|
80
|
+
404: NotFound,
|
|
81
|
+
429: RateLimited,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def exception_for_status(status: int) -> type[HTTPException]:
|
|
86
|
+
if status in STATUS_TO_EXC:
|
|
87
|
+
return STATUS_TO_EXC[status]
|
|
88
|
+
if 500 <= status < 600:
|
|
89
|
+
return ServerError
|
|
90
|
+
return HTTPException
|
pyyapper/http.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""HTTP client used by PyYapper.
|
|
2
|
+
|
|
3
|
+
Wraps :mod:`aiohttp` with the Yapper-specific conventions:
|
|
4
|
+
|
|
5
|
+
* ``Authorization: Bot <token>`` on every request.
|
|
6
|
+
* JSON request/response bodies.
|
|
7
|
+
* Automatic retry of ``429`` responses that carry a ``Retry-After`` header
|
|
8
|
+
(up to ``retries`` times).
|
|
9
|
+
* Translation of HTTP error statuses into the exception types in
|
|
10
|
+
:mod:`pyyapper.errors`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import json
|
|
17
|
+
import random
|
|
18
|
+
from typing import Any, Optional
|
|
19
|
+
|
|
20
|
+
import aiohttp
|
|
21
|
+
|
|
22
|
+
from .errors import (
|
|
23
|
+
HTTPException,
|
|
24
|
+
exception_for_status,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
DEFAULT_BASE = "https://www.yapper.website/api/bot/v1"
|
|
28
|
+
USER_AGENT = "pyyapper/1.0"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _backoff(n: int) -> float:
|
|
32
|
+
"""Exponential backoff with jitter, capped at 30s."""
|
|
33
|
+
base = min(30_000, 1000 * (2 ** (n - 1))) / 1000.0
|
|
34
|
+
return base * (0.75 + random.random() * 0.5)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class HTTPClient:
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
token: str,
|
|
41
|
+
*,
|
|
42
|
+
base_url: str = DEFAULT_BASE,
|
|
43
|
+
session: Optional[aiohttp.ClientSession] = None,
|
|
44
|
+
log: bool = True,
|
|
45
|
+
) -> None:
|
|
46
|
+
if not token or not str(token).startswith("yb_"):
|
|
47
|
+
raise ValueError("Pass your bot token (starts with yb_). Get one in Settings -> Developer.")
|
|
48
|
+
self.token = str(token).strip()
|
|
49
|
+
self.base_url = base_url.rstrip("/")
|
|
50
|
+
self.log = log
|
|
51
|
+
self._session = session
|
|
52
|
+
self._owns_session = session is None
|
|
53
|
+
|
|
54
|
+
async def _get_session(self) -> aiohttp.ClientSession:
|
|
55
|
+
if self._session is None or self._session.closed:
|
|
56
|
+
self._session = aiohttp.ClientSession()
|
|
57
|
+
self._owns_session = True
|
|
58
|
+
return self._session
|
|
59
|
+
|
|
60
|
+
async def close(self) -> None:
|
|
61
|
+
if self._owns_session and self._session is not None and not self._session.closed:
|
|
62
|
+
await self._session.close()
|
|
63
|
+
self._session = None
|
|
64
|
+
|
|
65
|
+
async def request(
|
|
66
|
+
self,
|
|
67
|
+
method: str,
|
|
68
|
+
path: str,
|
|
69
|
+
*,
|
|
70
|
+
body: Any = None,
|
|
71
|
+
headers: Optional[dict[str, str]] = None,
|
|
72
|
+
timeout: float = 30.0,
|
|
73
|
+
retries: int = 3,
|
|
74
|
+
content_type: Optional[str] = None,
|
|
75
|
+
) -> Any:
|
|
76
|
+
session = await self._get_session()
|
|
77
|
+
url = self.base_url + path
|
|
78
|
+
hdrs = {
|
|
79
|
+
"Authorization": f"Bot {self.token}",
|
|
80
|
+
"User-Agent": USER_AGENT,
|
|
81
|
+
}
|
|
82
|
+
if headers:
|
|
83
|
+
hdrs.update(headers)
|
|
84
|
+
data = None
|
|
85
|
+
if body is not None and not isinstance(body, (bytes, bytearray)):
|
|
86
|
+
# JSON body unless caller already provided bytes
|
|
87
|
+
if content_type is None:
|
|
88
|
+
content_type = "application/json"
|
|
89
|
+
data = json.dumps(body)
|
|
90
|
+
elif body is not None:
|
|
91
|
+
data = body
|
|
92
|
+
if content_type:
|
|
93
|
+
hdrs.setdefault("Content-Type", content_type)
|
|
94
|
+
|
|
95
|
+
for attempt in range(retries + 1):
|
|
96
|
+
try:
|
|
97
|
+
async with session.request(
|
|
98
|
+
method,
|
|
99
|
+
url,
|
|
100
|
+
data=data,
|
|
101
|
+
headers=hdrs,
|
|
102
|
+
timeout=aiohttp.ClientTimeout(total=timeout),
|
|
103
|
+
) as res:
|
|
104
|
+
raw = await res.read()
|
|
105
|
+
text = raw.decode("utf-8", errors="replace") if raw else ""
|
|
106
|
+
try:
|
|
107
|
+
payload = json.loads(text) if text else {}
|
|
108
|
+
except json.JSONDecodeError:
|
|
109
|
+
payload = {}
|
|
110
|
+
if res.status < 400:
|
|
111
|
+
return payload
|
|
112
|
+
# error path
|
|
113
|
+
err_msg = ""
|
|
114
|
+
if isinstance(payload, dict):
|
|
115
|
+
err_msg = payload.get("error") or payload.get("message") or ""
|
|
116
|
+
retry_after = None
|
|
117
|
+
ra = res.headers.get("Retry-After")
|
|
118
|
+
if ra is not None:
|
|
119
|
+
try:
|
|
120
|
+
retry_after = float(ra)
|
|
121
|
+
except ValueError:
|
|
122
|
+
retry_after = None
|
|
123
|
+
# retry on 429 with Retry-After
|
|
124
|
+
if res.status == 429 and retry_after and retry_after > 0 and retry_after <= 60 and attempt < retries:
|
|
125
|
+
if self.log:
|
|
126
|
+
print(f"[pyyapper] rate limited, retrying {method} {path} in {retry_after}s")
|
|
127
|
+
await asyncio.sleep(retry_after)
|
|
128
|
+
continue
|
|
129
|
+
exc_cls = exception_for_status(res.status)
|
|
130
|
+
raise exc_cls(
|
|
131
|
+
res.status,
|
|
132
|
+
err_msg or f"{method} {path} failed ({res.status})",
|
|
133
|
+
method=method,
|
|
134
|
+
path=path,
|
|
135
|
+
retry_after=retry_after,
|
|
136
|
+
)
|
|
137
|
+
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
|
|
138
|
+
if attempt < retries:
|
|
139
|
+
await asyncio.sleep(_backoff(attempt + 1))
|
|
140
|
+
continue
|
|
141
|
+
raise HTTPException(0, f"network error: {e}", method=method, path=path)
|
|
142
|
+
|
|
143
|
+
# Should not reach here
|
|
144
|
+
raise HTTPException(0, "request failed", method=method, path=path)
|
|
145
|
+
|
|
146
|
+
async def upload(
|
|
147
|
+
self,
|
|
148
|
+
method: str,
|
|
149
|
+
path: str,
|
|
150
|
+
image: Any,
|
|
151
|
+
content_type: Optional[str] = None,
|
|
152
|
+
*,
|
|
153
|
+
timeout: float = 60.0,
|
|
154
|
+
) -> Any:
|
|
155
|
+
"""Upload an image (bytes, path, or Blob-like)."""
|
|
156
|
+
import os
|
|
157
|
+
|
|
158
|
+
bytes_data = image
|
|
159
|
+
ct = content_type
|
|
160
|
+
if isinstance(image, str):
|
|
161
|
+
# file path
|
|
162
|
+
with open(image, "rb") as f:
|
|
163
|
+
bytes_data = f.read()
|
|
164
|
+
if ct is None:
|
|
165
|
+
ext = os.path.splitext(image)[1].lstrip(".").lower()
|
|
166
|
+
ct = {
|
|
167
|
+
"png": "image/png",
|
|
168
|
+
"jpg": "image/jpeg",
|
|
169
|
+
"jpeg": "image/jpeg",
|
|
170
|
+
"gif": "image/gif",
|
|
171
|
+
"webp": "image/webp",
|
|
172
|
+
}.get(ext)
|
|
173
|
+
elif hasattr(image, "read"):
|
|
174
|
+
bytes_data = image.read()
|
|
175
|
+
if hasattr(image, "seek"):
|
|
176
|
+
try:
|
|
177
|
+
image.seek(0)
|
|
178
|
+
except Exception:
|
|
179
|
+
pass
|
|
180
|
+
ct = ct or getattr(image, "content_type", None) or getattr(image, "type", None)
|
|
181
|
+
elif hasattr(image, "tobytes"):
|
|
182
|
+
bytes_data = image.tobytes() if callable(getattr(image, "tobytes")) else bytes(image)
|
|
183
|
+
else:
|
|
184
|
+
try:
|
|
185
|
+
bytes_data = bytes(image)
|
|
186
|
+
except Exception:
|
|
187
|
+
pass
|
|
188
|
+
if isinstance(bytes_data, str):
|
|
189
|
+
bytes_data = bytes_data.encode()
|
|
190
|
+
if not ct:
|
|
191
|
+
raise ValueError("Pass the image type too, like 'image/png'")
|
|
192
|
+
return await self.request(
|
|
193
|
+
method,
|
|
194
|
+
path,
|
|
195
|
+
body=bytes_data,
|
|
196
|
+
headers={"Content-Type": ct},
|
|
197
|
+
content_type=ct,
|
|
198
|
+
timeout=timeout,
|
|
199
|
+
retries=1,
|
|
200
|
+
)
|