twittrapi 0.2.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.
twittrapi/__init__.py ADDED
@@ -0,0 +1,55 @@
1
+ """Official Python client for TwittrAPI (https://twittrapi.com), a read API for X (Twitter) data.
2
+
3
+ from twittrapi import TwittrAPI
4
+
5
+ client = TwittrAPI(api_key="...")
6
+ user = client.users.get_by_username("jack")
7
+
8
+ # Compact, camelCase JSON instead of X's raw GraphQL (opt-in, per client or per call):
9
+ simple = TwittrAPI(api_key="...", response_format="simple")
10
+ user = client.users.get_by_username("jack", response_format="simple")
11
+
12
+ Not affiliated with X Corp.
13
+ """
14
+
15
+ from ._base import DEFAULT_BASE_URL, RAPIDAPI_BASE_URL, RAPIDAPI_HOST, ResponseFormat
16
+ from ._client import DEFAULT_MAX_PAGES, AsyncTwittrAPI, TwittrAPI
17
+ from ._exceptions import (
18
+ APIConnectionError,
19
+ APIStatusError,
20
+ APITimeoutError,
21
+ AuthenticationError,
22
+ InsufficientBalanceError,
23
+ NotFoundError,
24
+ RateLimitError,
25
+ ServerError,
26
+ TwittrAPIError,
27
+ ValidationError,
28
+ )
29
+ from ._endpoints import MAX_COUNT
30
+ from ._version import __version__
31
+ from .pagination import extract_bottom_cursor, simple_next_cursor
32
+
33
+ __all__ = [
34
+ "__version__",
35
+ "TwittrAPI",
36
+ "AsyncTwittrAPI",
37
+ "TwittrAPIError",
38
+ "APIConnectionError",
39
+ "APITimeoutError",
40
+ "APIStatusError",
41
+ "ValidationError",
42
+ "AuthenticationError",
43
+ "InsufficientBalanceError",
44
+ "NotFoundError",
45
+ "RateLimitError",
46
+ "ServerError",
47
+ "extract_bottom_cursor",
48
+ "simple_next_cursor",
49
+ "ResponseFormat",
50
+ "DEFAULT_BASE_URL",
51
+ "DEFAULT_MAX_PAGES",
52
+ "MAX_COUNT",
53
+ "RAPIDAPI_BASE_URL",
54
+ "RAPIDAPI_HOST",
55
+ ]
twittrapi/_base.py ADDED
@@ -0,0 +1,272 @@
1
+ """Shared logic for the sync and async clients: configuration, request building, error
2
+ mapping and retry timing. Nothing in here does I/O."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import email.utils
7
+ import json
8
+ import os
9
+ import random
10
+ import time
11
+ from dataclasses import dataclass
12
+ from typing import Any, Dict, Literal, Mapping, Optional, Union, cast
13
+
14
+ import httpx
15
+
16
+ from ._endpoints import QueryValue
17
+ from ._exceptions import (
18
+ APIStatusError,
19
+ AuthenticationError,
20
+ InsufficientBalanceError,
21
+ NotFoundError,
22
+ RateLimitError,
23
+ ServerError,
24
+ TwittrAPIError,
25
+ ValidationError,
26
+ )
27
+ from ._version import __version__
28
+
29
+ DEFAULT_BASE_URL = "https://api.twittrapi.com"
30
+ RAPIDAPI_HOST = "twitter-aio.p.rapidapi.com"
31
+ RAPIDAPI_BASE_URL = f"https://{RAPIDAPI_HOST}"
32
+ DEFAULT_TIMEOUT = 60.0
33
+ DEFAULT_MAX_RETRIES = 2
34
+
35
+ # Backoff for retries without a Retry-After header: 0.5s, 1s, 2s, ... capped, plus jitter.
36
+ INITIAL_RETRY_DELAY = 0.5
37
+ MAX_RETRY_DELAY = 8.0
38
+ # Upper bound for honoring a server-sent Retry-After, so a bad header cannot stall a script.
39
+ MAX_RETRY_AFTER = 60.0
40
+
41
+ ENV_API_KEY = "TWITTRAPI_API_KEY"
42
+ ENV_RAPIDAPI_KEY = "RAPIDAPI_KEY"
43
+ ENV_BASE_URL = "TWITTRAPI_BASE_URL"
44
+
45
+ USER_AGENT = f"twittrapi-python/{__version__}"
46
+
47
+ TimeoutTypes = Union[float, httpx.Timeout, None]
48
+
49
+ ResponseFormat = Literal["raw", "simple"]
50
+ """``"raw"``: X's JSON exactly as X sends it (the default). ``"simple"``: the API's compact,
51
+ camelCase format (``format=simple``), see https://twittrapi.com/docs/simple-format."""
52
+
53
+ RESPONSE_FORMATS = ("raw", "simple")
54
+ DEFAULT_RESPONSE_FORMAT: ResponseFormat = "raw"
55
+ FORMAT_PARAM = "format"
56
+
57
+
58
+ def check_response_format(value: Any, name: str = "response_format") -> ResponseFormat:
59
+ if value not in RESPONSE_FORMATS:
60
+ raise ValueError(f"{name} must be \"raw\" or \"simple\", got {value!r}")
61
+ return cast(ResponseFormat, value)
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class ClientConfig:
66
+ mode: str # "direct" or "rapidapi"
67
+ api_key: str
68
+ base_url: str
69
+
70
+ @property
71
+ def is_rapidapi(self) -> bool:
72
+ return self.mode == "rapidapi"
73
+
74
+
75
+ def _clean(value: Optional[str]) -> Optional[str]:
76
+ if value is None:
77
+ return None
78
+ value = value.strip()
79
+ return value or None
80
+
81
+
82
+ def resolve_config(
83
+ api_key: Optional[str],
84
+ rapidapi_key: Optional[str],
85
+ base_url: Optional[str],
86
+ env: Optional[Mapping[str, str]] = None,
87
+ ) -> ClientConfig:
88
+ """Picks the auth mode. Explicit arguments win over environment variables; within each,
89
+ the direct key wins over the RapidAPI key."""
90
+ env = os.environ if env is None else env
91
+ api_key = _clean(api_key)
92
+ rapidapi_key = _clean(rapidapi_key)
93
+
94
+ if api_key and rapidapi_key:
95
+ raise ValueError("Pass either api_key or rapidapi_key, not both.")
96
+
97
+ mode: Optional[str] = None
98
+ key: Optional[str] = None
99
+ if api_key:
100
+ mode, key = "direct", api_key
101
+ elif rapidapi_key:
102
+ mode, key = "rapidapi", rapidapi_key
103
+ elif _clean(env.get(ENV_API_KEY)):
104
+ mode, key = "direct", _clean(env.get(ENV_API_KEY))
105
+ elif _clean(env.get(ENV_RAPIDAPI_KEY)):
106
+ mode, key = "rapidapi", _clean(env.get(ENV_RAPIDAPI_KEY))
107
+
108
+ if not mode or not key:
109
+ raise TwittrAPIError(
110
+ "No API key found. Pass api_key=... (direct API, get one at https://twittrapi.com) "
111
+ "or rapidapi_key=... (RapidAPI), or set the TWITTRAPI_API_KEY or RAPIDAPI_KEY "
112
+ "environment variable."
113
+ )
114
+
115
+ url = _clean(base_url)
116
+ if url is None and mode == "direct":
117
+ url = _clean(env.get(ENV_BASE_URL))
118
+ if url is None:
119
+ url = RAPIDAPI_BASE_URL if mode == "rapidapi" else DEFAULT_BASE_URL
120
+ return ClientConfig(mode=mode, api_key=key, base_url=url.rstrip("/"))
121
+
122
+
123
+ def build_headers(config: ClientConfig) -> Dict[str, str]:
124
+ headers = {"Accept": "application/json", "User-Agent": USER_AGENT}
125
+ if config.is_rapidapi:
126
+ headers["x-rapidapi-key"] = config.api_key
127
+ headers["x-rapidapi-host"] = RAPIDAPI_HOST
128
+ else:
129
+ headers["X-API-KEY"] = config.api_key
130
+ return headers
131
+
132
+
133
+ def build_url(config: ClientConfig, path: str) -> str:
134
+ return config.base_url + (path if path.startswith("/") else "/" + path)
135
+
136
+
137
+ def clean_params(params: Mapping[str, Optional[QueryValue]]) -> Dict[str, QueryValue]:
138
+ return {k: v for k, v in params.items() if v is not None and v != ""}
139
+
140
+
141
+ def format_params(
142
+ params: Dict[str, QueryValue], response_format: ResponseFormat
143
+ ) -> Dict[str, QueryValue]:
144
+ """Adds ``format=simple`` for the simple format. Raw requests are sent exactly as before
145
+ (no ``format`` parameter), so their responses do not change."""
146
+ if response_format == "simple":
147
+ params = {**params, FORMAT_PARAM: "simple"}
148
+ return params
149
+
150
+
151
+ def make_timeout(timeout: TimeoutTypes) -> httpx.Timeout:
152
+ if isinstance(timeout, httpx.Timeout):
153
+ return timeout
154
+ return httpx.Timeout(timeout)
155
+
156
+
157
+ # ---------------------------------------------------------------------------
158
+ # Responses and errors
159
+ # ---------------------------------------------------------------------------
160
+
161
+
162
+ def _parse_body(response: httpx.Response) -> Any:
163
+ text = response.text
164
+ if not text.strip():
165
+ return None
166
+ try:
167
+ return json.loads(text)
168
+ except ValueError:
169
+ return text
170
+
171
+
172
+ def _error_text(body: Any) -> Optional[str]:
173
+ if isinstance(body, dict):
174
+ for key in ("error", "message", "detail"):
175
+ value = body.get(key)
176
+ if isinstance(value, str) and value.strip():
177
+ return value.strip()
178
+ if isinstance(body, str) and body.strip():
179
+ text = body.strip()
180
+ return text if len(text) <= 500 else text[:500] + "..."
181
+ return None
182
+
183
+
184
+ def parse_retry_after(value: Optional[str]) -> Optional[float]:
185
+ """Parses a Retry-After header given in seconds or as an HTTP date."""
186
+ if not value:
187
+ return None
188
+ value = value.strip()
189
+ try:
190
+ seconds = float(value)
191
+ except ValueError:
192
+ try:
193
+ parsed = email.utils.parsedate_to_datetime(value)
194
+ except (TypeError, ValueError, IndexError):
195
+ return None
196
+ if parsed is None:
197
+ return None
198
+ seconds = parsed.timestamp() - time.time()
199
+ return max(seconds, 0.0)
200
+
201
+
202
+ _DEFAULT_MESSAGES = {
203
+ 400: "Bad request. Check the parameters you passed.",
204
+ 401: "Authentication failed. Check your API key.",
205
+ 402: "No balance left on this TwittrAPI account. Top up at https://twittrapi.com.",
206
+ 403: "Access denied. The key may not have access to this endpoint, or X refused the request.",
207
+ 404: "Not found. The user, tweet, list or community may not exist, be private, or be suspended.",
208
+ 422: "Invalid input. Check the IDs and parameters you passed.",
209
+ 429: "Rate limit hit. Wait before retrying.",
210
+ }
211
+
212
+
213
+ def make_status_error(response: httpx.Response) -> APIStatusError:
214
+ status = response.status_code
215
+ body = _parse_body(response)
216
+ detail = _error_text(body)
217
+ if detail:
218
+ message = detail
219
+ elif status in _DEFAULT_MESSAGES:
220
+ message = _DEFAULT_MESSAGES[status]
221
+ elif status >= 500:
222
+ message = "TwittrAPI or X returned a server error. This is usually temporary."
223
+ else:
224
+ message = f"Request failed with status {status}."
225
+
226
+ kwargs: Dict[str, Any] = {"status_code": status, "body": body, "response": response}
227
+ if status in (400, 422):
228
+ return ValidationError(message, **kwargs)
229
+ if status in (401, 403):
230
+ return AuthenticationError(message, **kwargs)
231
+ if status == 402:
232
+ return InsufficientBalanceError(message, **kwargs)
233
+ if status == 404:
234
+ return NotFoundError(message, **kwargs)
235
+ if status == 429:
236
+ retry_after = parse_retry_after(response.headers.get("retry-after"))
237
+ return RateLimitError(message, retry_after=retry_after, **kwargs)
238
+ if status >= 500:
239
+ return ServerError(message, **kwargs)
240
+ return APIStatusError(message, **kwargs)
241
+
242
+
243
+ def process_response(response: httpx.Response) -> Any:
244
+ """Returns the parsed JSON body, or raises the matching exception for an error status."""
245
+ if response.status_code >= 400:
246
+ raise make_status_error(response)
247
+ body = _parse_body(response)
248
+ if isinstance(body, str):
249
+ raise TwittrAPIError(
250
+ "Expected a JSON response but got something else.",
251
+ status_code=response.status_code,
252
+ body=body,
253
+ response=response,
254
+ )
255
+ return body
256
+
257
+
258
+ # ---------------------------------------------------------------------------
259
+ # Retries
260
+ # ---------------------------------------------------------------------------
261
+
262
+
263
+ def should_retry_status(status: int) -> bool:
264
+ return status == 429 or status >= 500
265
+
266
+
267
+ def retry_delay(attempt: int, retry_after: Optional[float] = None) -> float:
268
+ """Seconds to wait before retry number ``attempt + 1`` (attempt starts at 0)."""
269
+ if retry_after is not None:
270
+ return min(retry_after, MAX_RETRY_AFTER)
271
+ base: float = min(INITIAL_RETRY_DELAY * (2**attempt), MAX_RETRY_DELAY)
272
+ return base * (1 + random.uniform(0, 0.25))
twittrapi/_client.py ADDED
@@ -0,0 +1,368 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import time
5
+ from types import TracebackType
6
+ from typing import (
7
+ Any,
8
+ AsyncIterator,
9
+ Awaitable,
10
+ Callable,
11
+ Dict,
12
+ Iterator,
13
+ Mapping,
14
+ Optional,
15
+ Set,
16
+ Type,
17
+ )
18
+
19
+ import httpx
20
+
21
+ from . import _base
22
+ from ._base import (
23
+ DEFAULT_MAX_RETRIES,
24
+ DEFAULT_RESPONSE_FORMAT,
25
+ DEFAULT_TIMEOUT,
26
+ ClientConfig,
27
+ ResponseFormat,
28
+ TimeoutTypes,
29
+ )
30
+ from ._endpoints import QueryValue
31
+ from ._exceptions import APIConnectionError, APITimeoutError, RateLimitError
32
+ from .pagination import extract_bottom_cursor, is_simple_page, page_is_empty, simple_next_cursor
33
+ from .resources import (
34
+ AsyncCommunities,
35
+ AsyncLists,
36
+ AsyncSearch,
37
+ AsyncTrends,
38
+ AsyncTweets,
39
+ AsyncUsers,
40
+ Communities,
41
+ Lists,
42
+ Search,
43
+ Trends,
44
+ Tweets,
45
+ Users,
46
+ )
47
+
48
+ __all__ = ["TwittrAPI", "AsyncTwittrAPI", "DEFAULT_MAX_PAGES"]
49
+
50
+ DEFAULT_MAX_PAGES = 10
51
+
52
+
53
+ def _check_max_retries(max_retries: int) -> int:
54
+ if isinstance(max_retries, bool) or not isinstance(max_retries, int) or max_retries < 0:
55
+ raise ValueError("max_retries must be an integer >= 0")
56
+ return max_retries
57
+
58
+
59
+ def _check_max_pages(max_pages: Optional[int]) -> None:
60
+ if max_pages is not None and (
61
+ isinstance(max_pages, bool) or not isinstance(max_pages, int) or max_pages < 1
62
+ ):
63
+ raise ValueError("max_pages must be a positive integer or None")
64
+
65
+
66
+ def _next_page_cursor(page: Any, response_format: ResponseFormat) -> Optional[str]:
67
+ """The cursor to request next, or ``None`` to stop.
68
+
69
+ Simple-format pages say it themselves (``nextCursor`` / ``hasNextPage``). Raw pages, and
70
+ simple-format requests the API answered raw (routes without a simple format), fall back
71
+ to the best-effort search for X's bottom cursor.
72
+ """
73
+ if response_format == "simple" and is_simple_page(page):
74
+ return simple_next_cursor(page)
75
+ next_cursor = extract_bottom_cursor(page)
76
+ if not next_cursor or page_is_empty(page):
77
+ return None
78
+ return next_cursor
79
+
80
+
81
+ class _ClientMixin:
82
+ _config: ClientConfig
83
+ max_retries: int
84
+ response_format: ResponseFormat
85
+
86
+ @property
87
+ def base_url(self) -> str:
88
+ return self._config.base_url
89
+
90
+ @property
91
+ def auth_mode(self) -> str:
92
+ """``"direct"`` (X-API-KEY header) or ``"rapidapi"`` (x-rapidapi-key header)."""
93
+ return self._config.mode
94
+
95
+ def _resolve_format(self, response_format: Optional[ResponseFormat]) -> ResponseFormat:
96
+ if response_format is None:
97
+ return self.response_format
98
+ return _base.check_response_format(response_format)
99
+
100
+ def _request_args(
101
+ self,
102
+ path: str,
103
+ params: Optional[Mapping[str, Optional[QueryValue]]],
104
+ response_format: Optional[ResponseFormat] = None,
105
+ ) -> Dict[str, Any]:
106
+ fmt = self._resolve_format(response_format)
107
+ return {
108
+ "url": _base.build_url(self._config, path),
109
+ "params": _base.format_params(_base.clean_params(params or {}), fmt),
110
+ "headers": _base.build_headers(self._config),
111
+ }
112
+
113
+ def __repr__(self) -> str:
114
+ return (
115
+ f"{type(self).__name__}(auth_mode={self.auth_mode!r}, base_url={self.base_url!r}, "
116
+ f"response_format={self.response_format!r})"
117
+ )
118
+
119
+
120
+ class TwittrAPI(_ClientMixin):
121
+ """Synchronous TwittrAPI client.
122
+
123
+ Auth: pass ``api_key`` for the direct API (https://twittrapi.com) or ``rapidapi_key`` for
124
+ RapidAPI. With neither, the ``TWITTRAPI_API_KEY`` and then ``RAPIDAPI_KEY`` environment
125
+ variables are used.
126
+
127
+ Every method returns the parsed JSON from the API. By default that is X's raw GraphQL
128
+ response. With ``response_format="simple"`` (here for every call, or per call on any
129
+ method) the API answers in its compact simple format (``{"data": ..., "nextCursor": ...,
130
+ "hasNextPage": ...}`` with flat, camelCase tweets and users); parse it with
131
+ :mod:`twittrapi.models` if you want typed objects.
132
+ Requests that fail with 429, 5xx or a network error are retried ``max_retries`` times
133
+ with exponential backoff. Other errors raise immediately.
134
+
135
+ Use it as a context manager, or call :meth:`close` when done.
136
+ """
137
+
138
+ users: Users
139
+ tweets: Tweets
140
+ search: Search
141
+ lists: Lists
142
+ communities: Communities
143
+ trends: Trends
144
+
145
+ def __init__(
146
+ self,
147
+ api_key: Optional[str] = None,
148
+ *,
149
+ rapidapi_key: Optional[str] = None,
150
+ base_url: Optional[str] = None,
151
+ timeout: TimeoutTypes = DEFAULT_TIMEOUT,
152
+ max_retries: int = DEFAULT_MAX_RETRIES,
153
+ http_client: Optional[httpx.Client] = None,
154
+ response_format: ResponseFormat = DEFAULT_RESPONSE_FORMAT,
155
+ ) -> None:
156
+ self._config = _base.resolve_config(api_key, rapidapi_key, base_url)
157
+ self.response_format = _base.check_response_format(response_format)
158
+ self.max_retries = _check_max_retries(max_retries)
159
+ self.timeout = _base.make_timeout(timeout)
160
+ self._owns_http = http_client is None
161
+ self._http = http_client or httpx.Client(timeout=self.timeout)
162
+ self._sleep: Callable[[float], None] = time.sleep
163
+
164
+ self.users = Users(self)
165
+ self.tweets = Tweets(self)
166
+ self.search = Search(self)
167
+ self.lists = Lists(self)
168
+ self.communities = Communities(self)
169
+ self.trends = Trends(self)
170
+
171
+ def _get(
172
+ self,
173
+ path: str,
174
+ params: Optional[Mapping[str, Optional[QueryValue]]] = None,
175
+ *,
176
+ response_format: Optional[ResponseFormat] = None,
177
+ ) -> Any:
178
+ """Sends a GET request with retries and returns the parsed JSON body."""
179
+ args = self._request_args(path, params, response_format)
180
+ attempt = 0
181
+ while True:
182
+ try:
183
+ response = self._http.get(timeout=self.timeout, **args)
184
+ except httpx.TimeoutException as exc:
185
+ if attempt < self.max_retries:
186
+ self._sleep(_base.retry_delay(attempt))
187
+ attempt += 1
188
+ continue
189
+ raise APITimeoutError(f"Request to TwittrAPI timed out: {exc}") from exc
190
+ except httpx.TransportError as exc:
191
+ if attempt < self.max_retries:
192
+ self._sleep(_base.retry_delay(attempt))
193
+ attempt += 1
194
+ continue
195
+ raise APIConnectionError(f"Could not reach TwittrAPI: {exc}") from exc
196
+
197
+ if attempt < self.max_retries and _base.should_retry_status(response.status_code):
198
+ error = _base.make_status_error(response)
199
+ retry_after = error.retry_after if isinstance(error, RateLimitError) else None
200
+ self._sleep(_base.retry_delay(attempt, retry_after))
201
+ attempt += 1
202
+ continue
203
+ return _base.process_response(response)
204
+
205
+ def paginate(
206
+ self,
207
+ method: Callable[..., Any],
208
+ *args: Any,
209
+ max_pages: Optional[int] = DEFAULT_MAX_PAGES,
210
+ **kwargs: Any,
211
+ ) -> Iterator[Any]:
212
+ """Calls a paginated method repeatedly and yields each page.
213
+
214
+ ``method`` is any client method that takes ``cursor``, e.g. ``client.users.followers``.
215
+ Other arguments are passed through. Pass ``cursor=`` to start from a saved cursor.
216
+
217
+ In the simple format (the client's ``response_format``, or ``response_format=`` passed
218
+ here for this iteration) each page's ``nextCursor`` is followed until ``hasNextPage``
219
+ is false. In the raw format X's bottom cursor is followed; that detection is
220
+ best-effort, see :mod:`twittrapi.pagination`.
221
+
222
+ Stops when ``max_pages`` pages have been fetched (``None`` for no limit), when there
223
+ is no next cursor, when the cursor repeats, or when a page has no results. Each page
224
+ is a billed request.
225
+ """
226
+ _check_max_pages(max_pages)
227
+ cursor: Optional[str] = kwargs.pop("cursor", None)
228
+ fmt = self._resolve_format(kwargs.get("response_format"))
229
+ seen: Set[str] = set()
230
+ pages = 0
231
+ while max_pages is None or pages < max_pages:
232
+ page = method(*args, cursor=cursor, **kwargs)
233
+ pages += 1
234
+ yield page
235
+ next_cursor = _next_page_cursor(page, fmt)
236
+ if not next_cursor or next_cursor in seen or next_cursor == cursor:
237
+ return
238
+ seen.add(next_cursor)
239
+ cursor = next_cursor
240
+
241
+ def close(self) -> None:
242
+ """Closes the underlying HTTP client (unless you passed your own ``http_client``)."""
243
+ if self._owns_http:
244
+ self._http.close()
245
+
246
+ def __enter__(self) -> "TwittrAPI":
247
+ return self
248
+
249
+ def __exit__(
250
+ self,
251
+ exc_type: Optional[Type[BaseException]],
252
+ exc: Optional[BaseException],
253
+ tb: Optional[TracebackType],
254
+ ) -> None:
255
+ self.close()
256
+
257
+
258
+ class AsyncTwittrAPI(_ClientMixin):
259
+ """Asynchronous TwittrAPI client. Same arguments and methods as :class:`TwittrAPI`, but
260
+ every API method is a coroutine and :meth:`paginate` is an async iterator.
261
+
262
+ Use it as an async context manager, or call ``await client.aclose()`` when done.
263
+ """
264
+
265
+ users: AsyncUsers
266
+ tweets: AsyncTweets
267
+ search: AsyncSearch
268
+ lists: AsyncLists
269
+ communities: AsyncCommunities
270
+ trends: AsyncTrends
271
+
272
+ def __init__(
273
+ self,
274
+ api_key: Optional[str] = None,
275
+ *,
276
+ rapidapi_key: Optional[str] = None,
277
+ base_url: Optional[str] = None,
278
+ timeout: TimeoutTypes = DEFAULT_TIMEOUT,
279
+ max_retries: int = DEFAULT_MAX_RETRIES,
280
+ http_client: Optional[httpx.AsyncClient] = None,
281
+ response_format: ResponseFormat = DEFAULT_RESPONSE_FORMAT,
282
+ ) -> None:
283
+ self._config = _base.resolve_config(api_key, rapidapi_key, base_url)
284
+ self.response_format = _base.check_response_format(response_format)
285
+ self.max_retries = _check_max_retries(max_retries)
286
+ self.timeout = _base.make_timeout(timeout)
287
+ self._owns_http = http_client is None
288
+ self._http = http_client or httpx.AsyncClient(timeout=self.timeout)
289
+ self._sleep: Callable[[float], Awaitable[None]] = asyncio.sleep
290
+
291
+ self.users = AsyncUsers(self)
292
+ self.tweets = AsyncTweets(self)
293
+ self.search = AsyncSearch(self)
294
+ self.lists = AsyncLists(self)
295
+ self.communities = AsyncCommunities(self)
296
+ self.trends = AsyncTrends(self)
297
+
298
+ async def _get(
299
+ self,
300
+ path: str,
301
+ params: Optional[Mapping[str, Optional[QueryValue]]] = None,
302
+ *,
303
+ response_format: Optional[ResponseFormat] = None,
304
+ ) -> Any:
305
+ args = self._request_args(path, params, response_format)
306
+ attempt = 0
307
+ while True:
308
+ try:
309
+ response = await self._http.get(timeout=self.timeout, **args)
310
+ except httpx.TimeoutException as exc:
311
+ if attempt < self.max_retries:
312
+ await self._sleep(_base.retry_delay(attempt))
313
+ attempt += 1
314
+ continue
315
+ raise APITimeoutError(f"Request to TwittrAPI timed out: {exc}") from exc
316
+ except httpx.TransportError as exc:
317
+ if attempt < self.max_retries:
318
+ await self._sleep(_base.retry_delay(attempt))
319
+ attempt += 1
320
+ continue
321
+ raise APIConnectionError(f"Could not reach TwittrAPI: {exc}") from exc
322
+
323
+ if attempt < self.max_retries and _base.should_retry_status(response.status_code):
324
+ error = _base.make_status_error(response)
325
+ retry_after = error.retry_after if isinstance(error, RateLimitError) else None
326
+ await self._sleep(_base.retry_delay(attempt, retry_after))
327
+ attempt += 1
328
+ continue
329
+ return _base.process_response(response)
330
+
331
+ async def paginate(
332
+ self,
333
+ method: Callable[..., Awaitable[Any]],
334
+ *args: Any,
335
+ max_pages: Optional[int] = DEFAULT_MAX_PAGES,
336
+ **kwargs: Any,
337
+ ) -> AsyncIterator[Any]:
338
+ """Async version of :meth:`TwittrAPI.paginate`. Use with ``async for``."""
339
+ _check_max_pages(max_pages)
340
+ cursor: Optional[str] = kwargs.pop("cursor", None)
341
+ fmt = self._resolve_format(kwargs.get("response_format"))
342
+ seen: Set[str] = set()
343
+ pages = 0
344
+ while max_pages is None or pages < max_pages:
345
+ page = await method(*args, cursor=cursor, **kwargs)
346
+ pages += 1
347
+ yield page
348
+ next_cursor = _next_page_cursor(page, fmt)
349
+ if not next_cursor or next_cursor in seen or next_cursor == cursor:
350
+ return
351
+ seen.add(next_cursor)
352
+ cursor = next_cursor
353
+
354
+ async def aclose(self) -> None:
355
+ """Closes the underlying HTTP client (unless you passed your own ``http_client``)."""
356
+ if self._owns_http:
357
+ await self._http.aclose()
358
+
359
+ async def __aenter__(self) -> "AsyncTwittrAPI":
360
+ return self
361
+
362
+ async def __aexit__(
363
+ self,
364
+ exc_type: Optional[Type[BaseException]],
365
+ exc: Optional[BaseException],
366
+ tb: Optional[TracebackType],
367
+ ) -> None:
368
+ await self.aclose()