actos 0.1.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.
Files changed (45) hide show
  1. actos/__init__.py +101 -0
  2. actos/_pagination.py +99 -0
  3. actos/_transport/__init__.py +8 -0
  4. actos/_transport/_async.py +224 -0
  5. actos/_transport/_sync.py +226 -0
  6. actos/client.py +266 -0
  7. actos/errors.py +337 -0
  8. actos/py.typed +0 -0
  9. actos/resources/_async/__init__.py +35 -0
  10. actos/resources/_async/actors.py +426 -0
  11. actos/resources/_async/admin.py +254 -0
  12. actos/resources/_async/auth.py +147 -0
  13. actos/resources/_async/base.py +15 -0
  14. actos/resources/_async/comments.py +194 -0
  15. actos/resources/_async/feed.py +180 -0
  16. actos/resources/_async/inbox.py +173 -0
  17. actos/resources/_async/meta.py +62 -0
  18. actos/resources/_async/posts.py +154 -0
  19. actos/resources/_async/reports.py +38 -0
  20. actos/resources/_async/saves.py +109 -0
  21. actos/resources/_async/search.py +89 -0
  22. actos/resources/_async/tags.py +160 -0
  23. actos/resources/_async/uploads.py +107 -0
  24. actos/resources/_async/votes.py +98 -0
  25. actos/resources/_sync/__init__.py +37 -0
  26. actos/resources/_sync/actors.py +428 -0
  27. actos/resources/_sync/admin.py +256 -0
  28. actos/resources/_sync/auth.py +149 -0
  29. actos/resources/_sync/base.py +17 -0
  30. actos/resources/_sync/comments.py +196 -0
  31. actos/resources/_sync/feed.py +182 -0
  32. actos/resources/_sync/inbox.py +173 -0
  33. actos/resources/_sync/meta.py +64 -0
  34. actos/resources/_sync/posts.py +156 -0
  35. actos/resources/_sync/reports.py +40 -0
  36. actos/resources/_sync/saves.py +111 -0
  37. actos/resources/_sync/search.py +91 -0
  38. actos/resources/_sync/tags.py +162 -0
  39. actos/resources/_sync/uploads.py +109 -0
  40. actos/resources/_sync/votes.py +100 -0
  41. actos/types.py +778 -0
  42. actos-0.1.0.dist-info/METADATA +280 -0
  43. actos-0.1.0.dist-info/RECORD +45 -0
  44. actos-0.1.0.dist-info/WHEEL +4 -0
  45. actos-0.1.0.dist-info/licenses/LICENSE +190 -0
actos/__init__.py ADDED
@@ -0,0 +1,101 @@
1
+ """Official Python SDK for the Actos platform (sync + async)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from actos import errors, types
6
+ from actos._pagination import Page
7
+ from actos.client import Actos, AsyncActos
8
+ from actos.errors import (
9
+ ActosAPIError,
10
+ ActosError,
11
+ ActosTransportError,
12
+ APIConnectionError,
13
+ APITimeoutError,
14
+ AuthenticationError,
15
+ BannedError,
16
+ ConflictError,
17
+ ForbiddenError,
18
+ GoneError,
19
+ InternalServerError,
20
+ InvalidCursorError,
21
+ InvalidKeyError,
22
+ NotFoundError,
23
+ RateLimitError,
24
+ UnsupportedMediaError,
25
+ ValidationError,
26
+ create_api_error,
27
+ )
28
+ from actos.resources._async.actors import UNSET, UnsetSentinel
29
+ from actos.resources._async.inbox import InboxPage
30
+ from actos.types import (
31
+ Actor,
32
+ ActorProfile,
33
+ AdminAction,
34
+ Attachment,
35
+ Ban,
36
+ Comment,
37
+ CommentDetail,
38
+ CommentNode,
39
+ CommentSummary,
40
+ ErrorCode,
41
+ FeedWindow,
42
+ HealthResponse,
43
+ MetaVersionResponse,
44
+ Post,
45
+ PostSort,
46
+ RateLimit,
47
+ Report,
48
+ Tag,
49
+ Upload,
50
+ )
51
+
52
+ __version__ = "0.1.0"
53
+
54
+ __all__ = [
55
+ "UNSET",
56
+ "APIConnectionError",
57
+ "APITimeoutError",
58
+ "Actor",
59
+ "ActorProfile",
60
+ "Actos",
61
+ "ActosAPIError",
62
+ "ActosError",
63
+ "ActosTransportError",
64
+ "AdminAction",
65
+ "AsyncActos",
66
+ "Attachment",
67
+ "AuthenticationError",
68
+ "Ban",
69
+ "BannedError",
70
+ "Comment",
71
+ "CommentDetail",
72
+ "CommentNode",
73
+ "CommentSummary",
74
+ "ConflictError",
75
+ "ErrorCode",
76
+ "FeedWindow",
77
+ "ForbiddenError",
78
+ "GoneError",
79
+ "HealthResponse",
80
+ "InboxPage",
81
+ "InternalServerError",
82
+ "InvalidCursorError",
83
+ "InvalidKeyError",
84
+ "MetaVersionResponse",
85
+ "NotFoundError",
86
+ "Page",
87
+ "Post",
88
+ "PostSort",
89
+ "RateLimit",
90
+ "RateLimitError",
91
+ "Report",
92
+ "Tag",
93
+ "UnsetSentinel",
94
+ "UnsupportedMediaError",
95
+ "Upload",
96
+ "ValidationError",
97
+ "__version__",
98
+ "create_api_error",
99
+ "errors",
100
+ "types",
101
+ ]
actos/_pagination.py ADDED
@@ -0,0 +1,99 @@
1
+ """Two-tier pagination: raw Page[T] and auto-paging iterators."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING, Generic, TypeVar
7
+
8
+ if TYPE_CHECKING:
9
+ from collections.abc import AsyncIterator, Awaitable, Callable, Iterator
10
+
11
+ T = TypeVar("T")
12
+
13
+
14
+ @dataclass
15
+ class Page(Generic[T]):
16
+ """A single page of paginated results returned by list() endpoints.
17
+
18
+ Attributes:
19
+ items: List of items on this page.
20
+ next_cursor: Opaque cursor string for requesting the subsequent page,
21
+ or None if this is the final page.
22
+ """
23
+
24
+ items: list[T]
25
+ next_cursor: str | None = None
26
+
27
+ def __len__(self) -> int:
28
+ return len(self.items)
29
+
30
+ def __iter__(self) -> Iterator[T]:
31
+ return iter(self.items)
32
+
33
+ def __getitem__(self, index: int) -> T:
34
+ return self.items[index]
35
+
36
+
37
+ class AsyncPaginator(Generic[T]):
38
+ """Asynchronous auto-paging iterator for iter_* methods."""
39
+
40
+ def __init__(
41
+ self,
42
+ fetch_page: Callable[[str | None], Awaitable[Page[T]]],
43
+ ) -> None:
44
+ self._fetch_page = fetch_page
45
+
46
+ def __aiter__(self) -> AsyncIterator[T]:
47
+ return self._iterate()
48
+
49
+ async def _iterate(self) -> AsyncIterator[T]:
50
+ cursor: str | None = None
51
+ has_more = True
52
+ while has_more:
53
+ page = await self._fetch_page(cursor)
54
+ for item in page.items:
55
+ yield item
56
+ cursor = page.next_cursor
57
+ if not cursor:
58
+ has_more = False
59
+
60
+ async def collect(self, limit: int | None = None) -> list[T]:
61
+ """Collect paginated items into a list, up to an optional limit."""
62
+ results: list[T] = []
63
+ async for item in self:
64
+ results.append(item)
65
+ if limit is not None and len(results) >= limit:
66
+ break
67
+ return results
68
+
69
+
70
+ class SyncPaginator(Generic[T]):
71
+ """Synchronous auto-paging iterator for iter_* methods."""
72
+
73
+ def __init__(
74
+ self,
75
+ fetch_page: Callable[[str | None], Page[T]],
76
+ ) -> None:
77
+ self._fetch_page = fetch_page
78
+
79
+ def __iter__(self) -> Iterator[T]:
80
+ return self._iterate()
81
+
82
+ def _iterate(self) -> Iterator[T]:
83
+ cursor: str | None = None
84
+ has_more = True
85
+ while has_more:
86
+ page = self._fetch_page(cursor)
87
+ yield from page.items
88
+ cursor = page.next_cursor
89
+ if not cursor:
90
+ has_more = False
91
+
92
+ def collect(self, limit: int | None = None) -> list[T]:
93
+ """Collect paginated items into a list, up to an optional limit."""
94
+ results: list[T] = []
95
+ for item in self:
96
+ results.append(item)
97
+ if limit is not None and len(results) >= limit:
98
+ break
99
+ return results
@@ -0,0 +1,8 @@
1
+ """Transport layer for Actos Python SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from actos._transport._async import AsyncTransport
6
+ from actos._transport._sync import SyncTransport
7
+
8
+ __all__ = ["AsyncTransport", "SyncTransport"]
@@ -0,0 +1,224 @@
1
+ """Asynchronous HTTP transport layer with retry, backoff, and rate-limit tracking."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import os
8
+ import random
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ import httpx
12
+
13
+ import actos
14
+ from actos.errors import (
15
+ APIConnectionError,
16
+ APITimeoutError,
17
+ create_api_error,
18
+ parse_retry_after,
19
+ )
20
+ from actos.types import RateLimit
21
+
22
+ if TYPE_CHECKING:
23
+ from types import TracebackType
24
+
25
+
26
+ def compute_backoff_delay(attempt: int, retry_after: float | None = None) -> float:
27
+ """Calculate delay for retry with exponential backoff and full jitter.
28
+
29
+ If Retry-After is specified, it takes absolute precedence (§2.8).
30
+ """
31
+ if retry_after is not None:
32
+ return retry_after
33
+
34
+ base_delay = 0.25
35
+ max_delay = 30.0
36
+ ceiling = min(max_delay, base_delay * (2**attempt))
37
+ return random.uniform(0.0, ceiling)
38
+
39
+
40
+ class AsyncTransport:
41
+ """Handles HTTP requests, connection pooling, headers, retries, and rate limits."""
42
+
43
+ def __init__(
44
+ self,
45
+ base_url: str | None = None,
46
+ api_key: str | None = None,
47
+ timeout: float = 30.0,
48
+ max_retries: int = 2,
49
+ client: httpx.AsyncClient | None = None,
50
+ ) -> None:
51
+ raw_base_url = (
52
+ base_url
53
+ if base_url is not None
54
+ else os.environ.get("ACTOS_BASE_URL", "http://127.0.0.1:3100")
55
+ )
56
+ self.base_url = raw_base_url.rstrip("/")
57
+ self.api_key = api_key if api_key is not None else os.environ.get("ACTOS_API_KEY")
58
+ self.timeout = timeout
59
+ self.max_retries = max_retries
60
+ self.rate_limit: RateLimit | None = None
61
+
62
+ self._custom_client = client is not None
63
+ self._client = client if client is not None else httpx.AsyncClient(timeout=timeout)
64
+
65
+ def _update_rate_limit(self, headers: httpx.Headers) -> None:
66
+ limit_str = headers.get("x-ratelimit-limit")
67
+ rem_str = headers.get("x-ratelimit-remaining")
68
+ reset_str = headers.get("x-ratelimit-reset")
69
+
70
+ if limit_str is not None and rem_str is not None and reset_str is not None:
71
+ with contextlib.suppress(ValueError, TypeError):
72
+ self.rate_limit = RateLimit(
73
+ limit=int(limit_str),
74
+ remaining=int(rem_str),
75
+ reset=int(reset_str),
76
+ )
77
+
78
+ def _raise_for_status(self, response: httpx.Response) -> None:
79
+ data: Any = None
80
+ content_type = response.headers.get("content-type", "")
81
+
82
+ if "application/json" in content_type or "application/problem+json" in content_type:
83
+ with contextlib.suppress(Exception):
84
+ data = response.json()
85
+
86
+ if data is None and response.text:
87
+ data = response.text
88
+
89
+ error = create_api_error(
90
+ status=response.status_code,
91
+ data=data,
92
+ headers=response.headers,
93
+ rate_limit=self.rate_limit,
94
+ )
95
+ raise error
96
+
97
+ def _format_params(self, params: dict[str, Any] | None) -> dict[str, str] | None:
98
+ if not params:
99
+ return None
100
+
101
+ formatted: dict[str, str] = {}
102
+ for k, v in params.items():
103
+ if v is None:
104
+ continue
105
+ if isinstance(v, (list, tuple, set)):
106
+ formatted[k] = ",".join(str(item) for item in v)
107
+ elif isinstance(v, bool):
108
+ formatted[k] = "true" if v else "false"
109
+ else:
110
+ formatted[k] = str(v)
111
+ return formatted
112
+
113
+ async def request(
114
+ self,
115
+ method: str,
116
+ path: str,
117
+ *,
118
+ params: dict[str, Any] | None = None,
119
+ json: Any = None,
120
+ data: Any = None,
121
+ files: Any = None,
122
+ headers: dict[str, str] | None = None,
123
+ idempotency_key: str | None = None,
124
+ timeout: float | None = None,
125
+ ) -> httpx.Response:
126
+ """Send an HTTP request with automatic retries and rate-limit tracking."""
127
+ if path.startswith(("http://", "https://")):
128
+ url = path
129
+ else:
130
+ clean_path = "/" + path.lstrip("/")
131
+ url = f"{self.base_url}{clean_path}"
132
+
133
+ req_headers = {
134
+ "Accept": "application/json",
135
+ "User-Agent": f"actos-python/{actos.__version__}",
136
+ }
137
+
138
+ if self.api_key:
139
+ req_headers["Authorization"] = f"Bearer {self.api_key}"
140
+
141
+ if idempotency_key:
142
+ req_headers["Idempotency-Key"] = idempotency_key
143
+
144
+ if headers:
145
+ req_headers.update(headers)
146
+
147
+ formatted_params = self._format_params(params)
148
+ timeout_val = timeout if timeout is not None else self.timeout
149
+
150
+ is_post = method.upper() == "POST"
151
+ has_idempotency_key = bool(
152
+ req_headers.get("Idempotency-Key") or req_headers.get("idempotency-key")
153
+ )
154
+ can_retry_5xx = (not is_post) or has_idempotency_key
155
+
156
+ for attempt in range(self.max_retries + 1):
157
+ try:
158
+ response = await self._client.request(
159
+ method=method,
160
+ url=url,
161
+ params=formatted_params,
162
+ json=json,
163
+ data=data,
164
+ files=files,
165
+ headers=req_headers,
166
+ timeout=timeout_val,
167
+ )
168
+
169
+ self._update_rate_limit(response.headers)
170
+
171
+ # 1. Successful response (< 400)
172
+ if response.status_code < 400:
173
+ return response
174
+
175
+ # 2. 429 Too Many Requests -> retry if attempt < max_retries
176
+ if response.status_code == 429 and attempt < self.max_retries:
177
+ retry_after = parse_retry_after(response.headers.get("Retry-After"))
178
+ delay = compute_backoff_delay(attempt, retry_after)
179
+ await asyncio.sleep(delay)
180
+ continue
181
+
182
+ # 3. 5xx Server Error -> retry if can_retry_5xx and attempt < max_retries
183
+ if (
184
+ 500 <= response.status_code <= 599
185
+ and can_retry_5xx
186
+ and attempt < self.max_retries
187
+ ):
188
+ delay = compute_backoff_delay(attempt)
189
+ await asyncio.sleep(delay)
190
+ continue
191
+
192
+ # 4. Other 4xx or retries exhausted -> raise specific ActosAPIError subclass
193
+ self._raise_for_status(response)
194
+
195
+ except (httpx.TimeoutException, httpx.TransportError) as exc:
196
+ # Network/Timeout error retry: safe requests or POST with idempotency key
197
+ if can_retry_5xx and attempt < self.max_retries:
198
+ delay = compute_backoff_delay(attempt)
199
+ await asyncio.sleep(delay)
200
+ continue
201
+
202
+ if isinstance(exc, httpx.TimeoutException):
203
+ raise APITimeoutError(f"Request timed out after {timeout_val}s") from exc
204
+ else:
205
+ raise APIConnectionError(f"Connection error: {exc}") from exc
206
+
207
+ # Should be unreachable given the loop raises on final attempt, but to satisfy typing:
208
+ raise APIConnectionError("Request failed: maximum retries exceeded")
209
+
210
+ async def aclose(self) -> None:
211
+ """Close the underlying HTTP client if created internally."""
212
+ if not self._custom_client:
213
+ await self._client.aclose()
214
+
215
+ async def __aenter__(self) -> AsyncTransport:
216
+ return self
217
+
218
+ async def __aexit__(
219
+ self,
220
+ exc_type: type[BaseException] | None,
221
+ exc_val: BaseException | None,
222
+ exc_tb: TracebackType | None,
223
+ ) -> None:
224
+ await self.aclose()
@@ -0,0 +1,226 @@
1
+ # BU DOSYA OTOMATİK OLARAK ÜRETİLMİŞTİR (unasync) — ELLE DÜZENLEMEYİNİZ.
2
+
3
+ """Synchronous HTTP transport layer with retry, backoff, and rate-limit tracking."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import contextlib
8
+ import os
9
+ import random
10
+ import time
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ import httpx
14
+
15
+ import actos
16
+ from actos.errors import (
17
+ APIConnectionError,
18
+ APITimeoutError,
19
+ create_api_error,
20
+ parse_retry_after,
21
+ )
22
+ from actos.types import RateLimit
23
+
24
+ if TYPE_CHECKING:
25
+ from types import TracebackType
26
+
27
+
28
+ def compute_backoff_delay(attempt: int, retry_after: float | None = None) -> float:
29
+ """Calculate delay for retry with exponential backoff and full jitter.
30
+
31
+ If Retry-After is specified, it takes absolute precedence (§2.8).
32
+ """
33
+ if retry_after is not None:
34
+ return retry_after
35
+
36
+ base_delay = 0.25
37
+ max_delay = 30.0
38
+ ceiling = min(max_delay, base_delay * (2**attempt))
39
+ return random.uniform(0.0, ceiling)
40
+
41
+
42
+ class SyncTransport:
43
+ """Handles HTTP requests, connection pooling, headers, retries, and rate limits."""
44
+
45
+ def __init__(
46
+ self,
47
+ base_url: str | None = None,
48
+ api_key: str | None = None,
49
+ timeout: float = 30.0,
50
+ max_retries: int = 2,
51
+ client: httpx.Client | None = None,
52
+ ) -> None:
53
+ raw_base_url = (
54
+ base_url
55
+ if base_url is not None
56
+ else os.environ.get("ACTOS_BASE_URL", "http://127.0.0.1:3100")
57
+ )
58
+ self.base_url = raw_base_url.rstrip("/")
59
+ self.api_key = api_key if api_key is not None else os.environ.get("ACTOS_API_KEY")
60
+ self.timeout = timeout
61
+ self.max_retries = max_retries
62
+ self.rate_limit: RateLimit | None = None
63
+
64
+ self._custom_client = client is not None
65
+ self._client = client if client is not None else httpx.Client(timeout=timeout)
66
+
67
+ def _update_rate_limit(self, headers: httpx.Headers) -> None:
68
+ limit_str = headers.get("x-ratelimit-limit")
69
+ rem_str = headers.get("x-ratelimit-remaining")
70
+ reset_str = headers.get("x-ratelimit-reset")
71
+
72
+ if limit_str is not None and rem_str is not None and reset_str is not None:
73
+ with contextlib.suppress(ValueError, TypeError):
74
+ self.rate_limit = RateLimit(
75
+ limit=int(limit_str),
76
+ remaining=int(rem_str),
77
+ reset=int(reset_str),
78
+ )
79
+
80
+ def _raise_for_status(self, response: httpx.Response) -> None:
81
+ data: Any = None
82
+ content_type = response.headers.get("content-type", "")
83
+
84
+ if "application/json" in content_type or "application/problem+json" in content_type:
85
+ with contextlib.suppress(Exception):
86
+ data = response.json()
87
+
88
+ if data is None and response.text:
89
+ data = response.text
90
+
91
+ error = create_api_error(
92
+ status=response.status_code,
93
+ data=data,
94
+ headers=response.headers,
95
+ rate_limit=self.rate_limit,
96
+ )
97
+ raise error
98
+
99
+ def _format_params(self, params: dict[str, Any] | None) -> dict[str, str] | None:
100
+ if not params:
101
+ return None
102
+
103
+ formatted: dict[str, str] = {}
104
+ for k, v in params.items():
105
+ if v is None:
106
+ continue
107
+ if isinstance(v, (list, tuple, set)):
108
+ formatted[k] = ",".join(str(item) for item in v)
109
+ elif isinstance(v, bool):
110
+ formatted[k] = "true" if v else "false"
111
+ else:
112
+ formatted[k] = str(v)
113
+ return formatted
114
+
115
+ def request(
116
+ self,
117
+ method: str,
118
+ path: str,
119
+ *,
120
+ params: dict[str, Any] | None = None,
121
+ json: Any = None,
122
+ data: Any = None,
123
+ files: Any = None,
124
+ headers: dict[str, str] | None = None,
125
+ idempotency_key: str | None = None,
126
+ timeout: float | None = None,
127
+ ) -> httpx.Response:
128
+ """Send an HTTP request with automatic retries and rate-limit tracking."""
129
+ if path.startswith(("http://", "https://")):
130
+ url = path
131
+ else:
132
+ clean_path = "/" + path.lstrip("/")
133
+ url = f"{self.base_url}{clean_path}"
134
+
135
+ req_headers = {
136
+ "Accept": "application/json",
137
+ "User-Agent": f"actos-python/{actos.__version__}",
138
+ }
139
+
140
+ if self.api_key:
141
+ req_headers["Authorization"] = f"Bearer {self.api_key}"
142
+
143
+ if idempotency_key:
144
+ req_headers["Idempotency-Key"] = idempotency_key
145
+
146
+ if headers:
147
+ req_headers.update(headers)
148
+
149
+ formatted_params = self._format_params(params)
150
+ timeout_val = timeout if timeout is not None else self.timeout
151
+
152
+ is_post = method.upper() == "POST"
153
+ has_idempotency_key = bool(
154
+ req_headers.get("Idempotency-Key") or req_headers.get("idempotency-key")
155
+ )
156
+ can_retry_5xx = (not is_post) or has_idempotency_key
157
+
158
+ for attempt in range(self.max_retries + 1):
159
+ try:
160
+ response = self._client.request(
161
+ method=method,
162
+ url=url,
163
+ params=formatted_params,
164
+ json=json,
165
+ data=data,
166
+ files=files,
167
+ headers=req_headers,
168
+ timeout=timeout_val,
169
+ )
170
+
171
+ self._update_rate_limit(response.headers)
172
+
173
+ # 1. Successful response (< 400)
174
+ if response.status_code < 400:
175
+ return response
176
+
177
+ # 2. 429 Too Many Requests -> retry if attempt < max_retries
178
+ if response.status_code == 429 and attempt < self.max_retries:
179
+ retry_after = parse_retry_after(response.headers.get("Retry-After"))
180
+ delay = compute_backoff_delay(attempt, retry_after)
181
+ time.sleep(delay)
182
+ continue
183
+
184
+ # 3. 5xx Server Error -> retry if can_retry_5xx and attempt < max_retries
185
+ if (
186
+ 500 <= response.status_code <= 599
187
+ and can_retry_5xx
188
+ and attempt < self.max_retries
189
+ ):
190
+ delay = compute_backoff_delay(attempt)
191
+ time.sleep(delay)
192
+ continue
193
+
194
+ # 4. Other 4xx or retries exhausted -> raise specific ActosAPIError subclass
195
+ self._raise_for_status(response)
196
+
197
+ except (httpx.TimeoutException, httpx.TransportError) as exc:
198
+ # Network/Timeout error retry: safe requests or POST with idempotency key
199
+ if can_retry_5xx and attempt < self.max_retries:
200
+ delay = compute_backoff_delay(attempt)
201
+ time.sleep(delay)
202
+ continue
203
+
204
+ if isinstance(exc, httpx.TimeoutException):
205
+ raise APITimeoutError(f"Request timed out after {timeout_val}s") from exc
206
+ else:
207
+ raise APIConnectionError(f"Connection error: {exc}") from exc
208
+
209
+ # Should be unreachable given the loop raises on final attempt, but to satisfy typing:
210
+ raise APIConnectionError("Request failed: maximum retries exceeded")
211
+
212
+ def close(self) -> None:
213
+ """Close the underlying HTTP client if created internally."""
214
+ if not self._custom_client:
215
+ self._client.close()
216
+
217
+ def __enter__(self) -> SyncTransport:
218
+ return self
219
+
220
+ def __exit__(
221
+ self,
222
+ exc_type: type[BaseException] | None,
223
+ exc_val: BaseException | None,
224
+ exc_tb: TracebackType | None,
225
+ ) -> None:
226
+ self.close()