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 +55 -0
- twittrapi/_base.py +272 -0
- twittrapi/_client.py +368 -0
- twittrapi/_endpoints.py +264 -0
- twittrapi/_exceptions.py +115 -0
- twittrapi/_version.py +1 -0
- twittrapi/models/__init__.py +355 -0
- twittrapi/pagination.py +165 -0
- twittrapi/py.typed +0 -0
- twittrapi/resources/__init__.py +21 -0
- twittrapi/resources/_base.py +31 -0
- twittrapi/resources/communities.py +80 -0
- twittrapi/resources/lists.py +80 -0
- twittrapi/resources/search.py +51 -0
- twittrapi/resources/trends.py +54 -0
- twittrapi/resources/tweets.py +114 -0
- twittrapi/resources/users.py +250 -0
- twittrapi-0.2.0.dist-info/METADATA +384 -0
- twittrapi-0.2.0.dist-info/RECORD +21 -0
- twittrapi-0.2.0.dist-info/WHEEL +4 -0
- twittrapi-0.2.0.dist-info/licenses/LICENSE +21 -0
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()
|