tweetapi 1.1.0__tar.gz → 2.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. {tweetapi-1.1.0 → tweetapi-2.0.0}/.gitignore +2 -0
  2. {tweetapi-1.1.0 → tweetapi-2.0.0}/PKG-INFO +91 -5
  3. {tweetapi-1.1.0 → tweetapi-2.0.0}/README.md +90 -4
  4. {tweetapi-1.1.0 → tweetapi-2.0.0}/pyproject.toml +8 -1
  5. {tweetapi-1.1.0 → tweetapi-2.0.0}/tweetapi/__init__.py +1 -1
  6. {tweetapi-1.1.0 → tweetapi-2.0.0}/tweetapi/client.py +2 -0
  7. {tweetapi-1.1.0 → tweetapi-2.0.0}/tweetapi/resources/auth.py +4 -3
  8. {tweetapi-1.1.0 → tweetapi-2.0.0}/tweetapi/resources/community.py +19 -2
  9. {tweetapi-1.1.0 → tweetapi-2.0.0}/tweetapi/resources/list_.py +23 -1
  10. {tweetapi-1.1.0 → tweetapi-2.0.0}/tweetapi/resources/post.py +12 -6
  11. tweetapi-2.0.0/tweetapi/resources/profile.py +31 -0
  12. {tweetapi-1.1.0 → tweetapi-2.0.0}/tweetapi/types.py +59 -1
  13. tweetapi-1.1.0/.claude/settings.local.json +0 -3
  14. tweetapi-1.1.0/examples/create_post.py +0 -37
  15. tweetapi-1.1.0/examples/get_user.py +0 -31
  16. tweetapi-1.1.0/examples/search_tweets.py +0 -20
  17. tweetapi-1.1.0/tests/test_client.py +0 -247
  18. tweetapi-1.1.0/tests/test_pagination.py +0 -101
  19. tweetapi-1.1.0/tests/test_retry.py +0 -148
  20. tweetapi-1.1.0/tests/test_timeout.py +0 -56
  21. tweetapi-1.1.0/tweetapi/resources/__init__.py +0 -0
  22. {tweetapi-1.1.0 → tweetapi-2.0.0}/tweetapi/errors.py +0 -0
  23. {tweetapi-1.1.0 → tweetapi-2.0.0}/tweetapi/pagination.py +0 -0
  24. {tweetapi-1.1.0 → tweetapi-2.0.0}/tweetapi/py.typed +0 -0
  25. {tweetapi-1.1.0/tests → tweetapi-2.0.0/tweetapi/resources}/__init__.py +0 -0
  26. {tweetapi-1.1.0 → tweetapi-2.0.0}/tweetapi/resources/explore.py +0 -0
  27. {tweetapi-1.1.0 → tweetapi-2.0.0}/tweetapi/resources/interaction.py +0 -0
  28. {tweetapi-1.1.0 → tweetapi-2.0.0}/tweetapi/resources/space.py +0 -0
  29. {tweetapi-1.1.0 → tweetapi-2.0.0}/tweetapi/resources/tweet.py +0 -0
  30. {tweetapi-1.1.0 → tweetapi-2.0.0}/tweetapi/resources/unencrypted_dm.py +0 -0
  31. {tweetapi-1.1.0 → tweetapi-2.0.0}/tweetapi/resources/user.py +0 -0
  32. {tweetapi-1.1.0 → tweetapi-2.0.0}/tweetapi/resources/xchat.py +0 -0
@@ -10,3 +10,5 @@ venv/
10
10
  .env
11
11
  .env.*
12
12
  .pytest_cache/
13
+ .claude/
14
+ AGENTS.md
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tweetapi
3
- Version: 1.1.0
3
+ Version: 2.0.0
4
4
  Summary: Official Python SDK for TweetAPI — Twitter/X Data API for developers and researchers
5
5
  Project-URL: Homepage, https://tweetapi.com?utm_source=pypi&utm_medium=readme&utm_campaign=python-sdk
6
6
  Project-URL: Documentation, https://tweetapi.com/docs?utm_source=pypi&utm_medium=readme&utm_campaign=python-sdk
@@ -113,13 +113,21 @@ next_page = client.user.get_followers(
113
113
 
114
114
  | Method | Description |
115
115
  |--------|-------------|
116
- | `client.post.create_post(auth_token=..., text=..., proxy=...)` | Create a tweet |
117
- | `client.post.create_post_quote(auth_token=..., text=..., attachment_url=..., proxy=...)` | Quote tweet |
118
- | `client.post.create_post_with_media(auth_token=..., text=..., media=..., proxy=...)` | Tweet with media |
116
+ | `client.post.create_post(auth_token=..., text=..., proxy=..., reply_option=...)` | Create a tweet |
117
+ | `client.post.create_post_quote(auth_token=..., text=..., attachment_url=..., proxy=..., reply_option=...)` | Quote tweet |
118
+ | `client.post.create_post_with_media(auth_token=..., text=..., media=..., proxy=..., reply_option=...)` | Tweet with media |
119
119
  | `client.post.reply_post(auth_token=..., text=..., tweet_id=..., proxy=...)` | Reply to a tweet |
120
120
  | `client.post.reply_post_with_media(...)` | Reply with media |
121
121
  | `client.post.delete_post(auth_token=..., tweet_id=...)` | Delete a tweet |
122
122
 
123
+ ### Profile
124
+
125
+ | Method | Description |
126
+ |--------|-------------|
127
+ | `client.profile.update(auth_token=..., name=..., bio=..., location=..., website=..., proxy=...)` | Update profile fields |
128
+ | `client.profile.avatar(auth_token=..., media=..., proxy=...)` | Update profile avatar |
129
+ | `client.profile.banner(auth_token=..., media=..., proxy=...)` | Update profile banner |
130
+
123
131
  ### Interaction
124
132
 
125
133
  | Method | Description |
@@ -145,6 +153,9 @@ next_page = client.user.get_followers(
145
153
  | `client.list.get_tweets(list_id=...)` | Get tweets in a list |
146
154
  | `client.list.get_members(list_id=...)` | Get list members |
147
155
  | `client.list.get_followers(list_id=...)` | Get list followers |
156
+ | `client.list.create(auth_token=..., name=..., description=..., is_private=...)` | Create a list |
157
+ | `client.list.add_member(auth_token=..., list_id=..., user_id=..., proxy=...)` | Add a member |
158
+ | `client.list.remove_member(auth_token=..., list_id=..., user_id=..., proxy=...)` | Remove a member |
148
159
 
149
160
  ### Community
150
161
 
@@ -156,6 +167,8 @@ next_page = client.user.get_followers(
156
167
  | `client.community.search(query=...)` | Search communities |
157
168
  | `client.community.create_post(auth_token=..., text=..., community_id=..., proxy=...)` | Post in community |
158
169
  | `client.community.create_post_with_media(...)` | Post with media |
170
+ | `client.community.create_quote(auth_token=..., text=..., attachment_url=..., community_id=..., proxy=...)` | Quote post in community |
171
+ | `client.community.create_quote_with_media(...)` | Quote post with media |
159
172
  | `client.community.reply_post(...)` | Reply to community post |
160
173
  | `client.community.reply_post_with_media(...)` | Reply with media |
161
174
  | `client.community.join(auth_token=..., community_id=...)` | Join |
@@ -178,7 +191,9 @@ next_page = client.user.get_followers(
178
191
 
179
192
  | Method | Description |
180
193
  |--------|-------------|
181
- | `client.auth.login(username=..., password=..., proxy=...)` | Log in, get auth tokens |
194
+ | `client.auth.login(username=..., password=..., proxy=..., country=...)` | Log in, get auth tokens |
195
+
196
+ `country` is the ISO 3166-1 alpha-2 code for the proxy's public egress IP (for example, `"US"`). It must match the IP used for the complete login attempt. Pass `two_factor_secret` when the account uses TOTP-based 2FA.
182
197
 
183
198
  ### X Chat (Encrypted DMs)
184
199
 
@@ -203,6 +218,77 @@ next_page = client.user.get_followers(
203
218
  | `client.dm.get_dm_user_updates(auth_token=..., cursor=...)` | DM user updates |
204
219
  | `client.dm.accept_conversation(auth_token=..., conversation_id=...)` | Accept request |
205
220
 
221
+ ## Posting and Profile Media
222
+
223
+ Tweet media accepts an existing TweetAPI media ID, a URL, or inline base64 data:
224
+
225
+ ```python
226
+ client.post.create_post_with_media(
227
+ auth_token="AUTH_TOKEN",
228
+ text="Launch update",
229
+ media=[{"media_id": "1971008286821380096"}],
230
+ proxy="host:port:user:pass",
231
+ )
232
+ ```
233
+
234
+ Use `reply_option` to set reply controls when creating standard, quote, or media posts:
235
+
236
+ ```python
237
+ client.post.create_post(
238
+ auth_token="AUTH_TOKEN",
239
+ text="Followers can reply",
240
+ proxy="host:port:user:pass",
241
+ reply_option={"mode": "followers"},
242
+ )
243
+ ```
244
+
245
+ Profile avatar and banner uploads accept a URL or inline `data` plus `type`:
246
+
247
+ ```python
248
+ client.profile.update(auth_token="AUTH_TOKEN", name="New Name", bio="Builder")
249
+ client.profile.avatar(
250
+ auth_token="AUTH_TOKEN",
251
+ media={"url": "https://example.com/avatar.jpg"},
252
+ )
253
+ client.profile.banner(
254
+ auth_token="AUTH_TOKEN",
255
+ media={"data": "BASE64_IMAGE_DATA", "type": "image/png"},
256
+ )
257
+ ```
258
+
259
+ Canonical list mutations live under `client.list`; the legacy interaction list helpers remain available:
260
+
261
+ ```python
262
+ created = client.list.create(
263
+ auth_token="AUTH_TOKEN",
264
+ name="Research",
265
+ description="Accounts to watch",
266
+ is_private=True,
267
+ )
268
+ client.list.add_member(auth_token="AUTH_TOKEN", list_id=created["data"]["id"], user_id="123")
269
+ client.list.remove_member(auth_token="AUTH_TOKEN", list_id=created["data"]["id"], user_id="123")
270
+ ```
271
+
272
+ Community quote posts support the same URL and media attachment shapes:
273
+
274
+ ```python
275
+ client.community.create_quote(
276
+ auth_token="AUTH_TOKEN",
277
+ text="Useful context",
278
+ attachment_url="https://x.com/user/status/123",
279
+ community_id="987",
280
+ proxy="host:port:user:pass",
281
+ )
282
+ client.community.create_quote_with_media(
283
+ auth_token="AUTH_TOKEN",
284
+ text="Useful context",
285
+ attachment_url="https://x.com/user/status/123",
286
+ community_id="987",
287
+ media=[{"media_id": "1971008286821380096"}],
288
+ proxy="host:port:user:pass",
289
+ )
290
+ ```
291
+
206
292
  ## Auto-Pagination
207
293
 
208
294
  Use the `paginate()` and `paginate_pages()` helpers to iterate through all pages automatically:
@@ -83,13 +83,21 @@ next_page = client.user.get_followers(
83
83
 
84
84
  | Method | Description |
85
85
  |--------|-------------|
86
- | `client.post.create_post(auth_token=..., text=..., proxy=...)` | Create a tweet |
87
- | `client.post.create_post_quote(auth_token=..., text=..., attachment_url=..., proxy=...)` | Quote tweet |
88
- | `client.post.create_post_with_media(auth_token=..., text=..., media=..., proxy=...)` | Tweet with media |
86
+ | `client.post.create_post(auth_token=..., text=..., proxy=..., reply_option=...)` | Create a tweet |
87
+ | `client.post.create_post_quote(auth_token=..., text=..., attachment_url=..., proxy=..., reply_option=...)` | Quote tweet |
88
+ | `client.post.create_post_with_media(auth_token=..., text=..., media=..., proxy=..., reply_option=...)` | Tweet with media |
89
89
  | `client.post.reply_post(auth_token=..., text=..., tweet_id=..., proxy=...)` | Reply to a tweet |
90
90
  | `client.post.reply_post_with_media(...)` | Reply with media |
91
91
  | `client.post.delete_post(auth_token=..., tweet_id=...)` | Delete a tweet |
92
92
 
93
+ ### Profile
94
+
95
+ | Method | Description |
96
+ |--------|-------------|
97
+ | `client.profile.update(auth_token=..., name=..., bio=..., location=..., website=..., proxy=...)` | Update profile fields |
98
+ | `client.profile.avatar(auth_token=..., media=..., proxy=...)` | Update profile avatar |
99
+ | `client.profile.banner(auth_token=..., media=..., proxy=...)` | Update profile banner |
100
+
93
101
  ### Interaction
94
102
 
95
103
  | Method | Description |
@@ -115,6 +123,9 @@ next_page = client.user.get_followers(
115
123
  | `client.list.get_tweets(list_id=...)` | Get tweets in a list |
116
124
  | `client.list.get_members(list_id=...)` | Get list members |
117
125
  | `client.list.get_followers(list_id=...)` | Get list followers |
126
+ | `client.list.create(auth_token=..., name=..., description=..., is_private=...)` | Create a list |
127
+ | `client.list.add_member(auth_token=..., list_id=..., user_id=..., proxy=...)` | Add a member |
128
+ | `client.list.remove_member(auth_token=..., list_id=..., user_id=..., proxy=...)` | Remove a member |
118
129
 
119
130
  ### Community
120
131
 
@@ -126,6 +137,8 @@ next_page = client.user.get_followers(
126
137
  | `client.community.search(query=...)` | Search communities |
127
138
  | `client.community.create_post(auth_token=..., text=..., community_id=..., proxy=...)` | Post in community |
128
139
  | `client.community.create_post_with_media(...)` | Post with media |
140
+ | `client.community.create_quote(auth_token=..., text=..., attachment_url=..., community_id=..., proxy=...)` | Quote post in community |
141
+ | `client.community.create_quote_with_media(...)` | Quote post with media |
129
142
  | `client.community.reply_post(...)` | Reply to community post |
130
143
  | `client.community.reply_post_with_media(...)` | Reply with media |
131
144
  | `client.community.join(auth_token=..., community_id=...)` | Join |
@@ -148,7 +161,9 @@ next_page = client.user.get_followers(
148
161
 
149
162
  | Method | Description |
150
163
  |--------|-------------|
151
- | `client.auth.login(username=..., password=..., proxy=...)` | Log in, get auth tokens |
164
+ | `client.auth.login(username=..., password=..., proxy=..., country=...)` | Log in, get auth tokens |
165
+
166
+ `country` is the ISO 3166-1 alpha-2 code for the proxy's public egress IP (for example, `"US"`). It must match the IP used for the complete login attempt. Pass `two_factor_secret` when the account uses TOTP-based 2FA.
152
167
 
153
168
  ### X Chat (Encrypted DMs)
154
169
 
@@ -173,6 +188,77 @@ next_page = client.user.get_followers(
173
188
  | `client.dm.get_dm_user_updates(auth_token=..., cursor=...)` | DM user updates |
174
189
  | `client.dm.accept_conversation(auth_token=..., conversation_id=...)` | Accept request |
175
190
 
191
+ ## Posting and Profile Media
192
+
193
+ Tweet media accepts an existing TweetAPI media ID, a URL, or inline base64 data:
194
+
195
+ ```python
196
+ client.post.create_post_with_media(
197
+ auth_token="AUTH_TOKEN",
198
+ text="Launch update",
199
+ media=[{"media_id": "1971008286821380096"}],
200
+ proxy="host:port:user:pass",
201
+ )
202
+ ```
203
+
204
+ Use `reply_option` to set reply controls when creating standard, quote, or media posts:
205
+
206
+ ```python
207
+ client.post.create_post(
208
+ auth_token="AUTH_TOKEN",
209
+ text="Followers can reply",
210
+ proxy="host:port:user:pass",
211
+ reply_option={"mode": "followers"},
212
+ )
213
+ ```
214
+
215
+ Profile avatar and banner uploads accept a URL or inline `data` plus `type`:
216
+
217
+ ```python
218
+ client.profile.update(auth_token="AUTH_TOKEN", name="New Name", bio="Builder")
219
+ client.profile.avatar(
220
+ auth_token="AUTH_TOKEN",
221
+ media={"url": "https://example.com/avatar.jpg"},
222
+ )
223
+ client.profile.banner(
224
+ auth_token="AUTH_TOKEN",
225
+ media={"data": "BASE64_IMAGE_DATA", "type": "image/png"},
226
+ )
227
+ ```
228
+
229
+ Canonical list mutations live under `client.list`; the legacy interaction list helpers remain available:
230
+
231
+ ```python
232
+ created = client.list.create(
233
+ auth_token="AUTH_TOKEN",
234
+ name="Research",
235
+ description="Accounts to watch",
236
+ is_private=True,
237
+ )
238
+ client.list.add_member(auth_token="AUTH_TOKEN", list_id=created["data"]["id"], user_id="123")
239
+ client.list.remove_member(auth_token="AUTH_TOKEN", list_id=created["data"]["id"], user_id="123")
240
+ ```
241
+
242
+ Community quote posts support the same URL and media attachment shapes:
243
+
244
+ ```python
245
+ client.community.create_quote(
246
+ auth_token="AUTH_TOKEN",
247
+ text="Useful context",
248
+ attachment_url="https://x.com/user/status/123",
249
+ community_id="987",
250
+ proxy="host:port:user:pass",
251
+ )
252
+ client.community.create_quote_with_media(
253
+ auth_token="AUTH_TOKEN",
254
+ text="Useful context",
255
+ attachment_url="https://x.com/user/status/123",
256
+ community_id="987",
257
+ media=[{"media_id": "1971008286821380096"}],
258
+ proxy="host:port:user:pass",
259
+ )
260
+ ```
261
+
176
262
  ## Auto-Pagination
177
263
 
178
264
  Use the `paginate()` and `paginate_pages()` helpers to iterate through all pages automatically:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "tweetapi"
7
- version = "1.1.0"
7
+ version = "2.0.0"
8
8
  description = "Official Python SDK for TweetAPI — Twitter/X Data API for developers and researchers"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -53,6 +53,13 @@ Issues = "https://github.com/tweetapi/python/issues"
53
53
  [tool.hatch.build.targets.wheel]
54
54
  packages = ["tweetapi"]
55
55
 
56
+ [tool.hatch.build.targets.sdist]
57
+ include = [
58
+ "/tweetapi",
59
+ "/README.md",
60
+ "/pyproject.toml",
61
+ ]
62
+
56
63
  [tool.pytest.ini_options]
57
64
  testpaths = ["tests"]
58
65
 
@@ -37,4 +37,4 @@ __all__ = [
37
37
  "paginate_pages",
38
38
  ]
39
39
 
40
- __version__ = "1.1.0"
40
+ __version__ = "2.0.0"
@@ -78,6 +78,7 @@ class TweetAPI:
78
78
  from .resources.post import PostResource
79
79
  from .resources.interaction import InteractionResource
80
80
  from .resources.list_ import ListResource
81
+ from .resources.profile import ProfileResource
81
82
  from .resources.community import CommunityResource
82
83
  from .resources.space import SpaceResource
83
84
  from .resources.explore import ExploreResource
@@ -90,6 +91,7 @@ class TweetAPI:
90
91
  self.post = PostResource(self)
91
92
  self.interaction = InteractionResource(self)
92
93
  self.list = ListResource(self)
94
+ self.profile = ProfileResource(self)
93
95
  self.community = CommunityResource(self)
94
96
  self.space = SpaceResource(self)
95
97
  self.explore = ExploreResource(self)
@@ -11,9 +11,10 @@ class AuthResource:
11
11
  def __init__(self, client: TweetAPI) -> None:
12
12
  self._client = client
13
13
 
14
- def login(self, *, username: str, password: str, proxy: str, two_factor_secret: Optional[str] = None) -> LoginApiResponse:
15
- """Log in to a Twitter account and get auth tokens."""
14
+ def login(self, *, username: str, password: str, proxy: str, country: str, two_factor_secret: Optional[str] = None) -> LoginApiResponse:
15
+ """Log in using the proxy egress country and get auth tokens."""
16
16
  return self._client._post("/tw-v2/auth/login", {
17
17
  "username": username, "password": password,
18
- "proxy": proxy, "twoFactorSecret": two_factor_secret,
18
+ "proxy": proxy, "country": country,
19
+ "twoFactorSecret": two_factor_secret,
19
20
  })
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Optional, TYPE_CHECKING
3
+ from typing import Optional, TYPE_CHECKING
4
4
 
5
5
  if TYPE_CHECKING:
6
6
  from ..client import TweetAPI
@@ -9,6 +9,7 @@ if TYPE_CHECKING:
9
9
  CommunityResponse,
10
10
  CommunityMemberPaginatedResponse,
11
11
  CommunitySearchPaginatedResponse,
12
+ TweetMediaInput,
12
13
  TweetsPaginatedResponse,
13
14
  )
14
15
 
@@ -40,13 +41,29 @@ class CommunityResource:
40
41
  "proxy": proxy, "disableLinkPreview": disable_link_preview,
41
42
  })
42
43
 
43
- def create_post_with_media(self, *, auth_token: str, text: str, community_id: str, media: list[Any], proxy: str, disable_link_preview: Optional[bool] = None) -> ActionResponse:
44
+ def create_post_with_media(self, *, auth_token: str, text: str, community_id: str, media: list[TweetMediaInput], proxy: str, disable_link_preview: Optional[bool] = None) -> ActionResponse:
44
45
  """Create a post with media in a community."""
45
46
  return self._client._post("/tw-v2/interaction/create-community-post-with-media", {
46
47
  "authToken": auth_token, "text": text, "communityId": community_id,
47
48
  "media": media, "proxy": proxy, "disableLinkPreview": disable_link_preview,
48
49
  })
49
50
 
51
+ def create_quote(self, *, auth_token: str, text: str, attachment_url: str, community_id: str, proxy: str, disable_link_preview: Optional[bool] = None) -> ActionResponse:
52
+ """Create a quote post in a community."""
53
+ return self._client._post("/tw-v2/interaction/create-community-quote", {
54
+ "authToken": auth_token, "text": text, "attachmentUrl": attachment_url,
55
+ "communityId": community_id, "proxy": proxy,
56
+ "disableLinkPreview": disable_link_preview,
57
+ })
58
+
59
+ def create_quote_with_media(self, *, auth_token: str, text: str, attachment_url: str, community_id: str, media: list[TweetMediaInput], proxy: str, disable_link_preview: Optional[bool] = None) -> ActionResponse:
60
+ """Create a quote post with media in a community."""
61
+ return self._client._post("/tw-v2/interaction/create-community-quote-with-media", {
62
+ "authToken": auth_token, "text": text, "attachmentUrl": attachment_url,
63
+ "communityId": community_id, "media": media, "proxy": proxy,
64
+ "disableLinkPreview": disable_link_preview,
65
+ })
66
+
50
67
  def reply_post(self, *, auth_token: str, text: str, tweet_id: str, community_id: str, proxy: str, disable_link_preview: Optional[bool] = None) -> ActionResponse:
51
68
  """Reply to a community post."""
52
69
  return self._client._post("/tw-v2/interaction/reply-community-post", {
@@ -1,10 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Optional, TYPE_CHECKING
3
+ from typing import Optional, TYPE_CHECKING
4
4
 
5
5
  if TYPE_CHECKING:
6
6
  from ..client import TweetAPI
7
7
  from ..types import (
8
+ ActionResponse,
8
9
  ListResponse,
9
10
  TweetsPaginatedResponse,
10
11
  UserPaginatedResponse,
@@ -30,3 +31,24 @@ class ListResource:
30
31
  def get_followers(self, *, list_id: str, cursor: Optional[str] = None) -> UserPaginatedResponse:
31
32
  """Get followers of a list."""
32
33
  return self._client._get("/tw-v2/list/followers", {"listId": list_id, "cursor": cursor})
34
+
35
+ def create(self, *, auth_token: str, name: str, description: str = "", is_private: bool = True) -> ListResponse:
36
+ """Create a list."""
37
+ return self._client._post("/tw-v2/list/create", {
38
+ "authToken": auth_token, "name": name, "description": description,
39
+ "isPrivate": is_private,
40
+ })
41
+
42
+ def add_member(self, *, auth_token: str, list_id: str, user_id: str, proxy: Optional[str] = None) -> ActionResponse:
43
+ """Add a user to a list."""
44
+ return self._client._post("/tw-v2/list/add-member", {
45
+ "authToken": auth_token, "listId": list_id, "userId": user_id,
46
+ "proxy": proxy,
47
+ })
48
+
49
+ def remove_member(self, *, auth_token: str, list_id: str, user_id: str, proxy: Optional[str] = None) -> ActionResponse:
50
+ """Remove a user from a list."""
51
+ return self._client._post("/tw-v2/list/remove-member", {
52
+ "authToken": auth_token, "listId": list_id, "userId": user_id,
53
+ "proxy": proxy,
54
+ })
@@ -4,32 +4,38 @@ from typing import Any, Optional, TYPE_CHECKING
4
4
 
5
5
  if TYPE_CHECKING:
6
6
  from ..client import TweetAPI
7
- from ..types import ActionResponse
7
+ from ..types import ActionResponse, ReplyOption, TweetMediaInput
8
8
 
9
9
 
10
10
  class PostResource:
11
11
  def __init__(self, client: TweetAPI) -> None:
12
12
  self._client = client
13
13
 
14
- def create_post(self, *, auth_token: str, text: str, proxy: str, disable_link_preview: Optional[bool] = None) -> ActionResponse:
14
+ def create_post(self, *, auth_token: str, text: str, proxy: str, disable_link_preview: Optional[bool] = None, reply_option: Optional[ReplyOption] = None) -> ActionResponse:
15
15
  """Create a new tweet."""
16
16
  return self._client._post("/tw-v2/interaction/create-post", {
17
17
  "authToken": auth_token, "text": text, "proxy": proxy,
18
- "disableLinkPreview": disable_link_preview,
18
+ "disableLinkPreview": disable_link_preview, "replyOption": reply_option,
19
19
  })
20
20
 
21
- def create_post_quote(self, *, auth_token: str, text: str, attachment_url: str, proxy: str, disable_link_preview: Optional[bool] = None) -> ActionResponse:
21
+ def create_post_quote(self, *, auth_token: str, text: str, attachment_url: str, proxy: str, disable_link_preview: Optional[bool] = None, reply_option: Optional[ReplyOption] = None) -> ActionResponse:
22
22
  """Create a quote tweet."""
23
23
  return self._client._post("/tw-v2/interaction/create-post-quote", {
24
24
  "authToken": auth_token, "text": text, "attachmentUrl": attachment_url,
25
25
  "proxy": proxy, "disableLinkPreview": disable_link_preview,
26
+ "replyOption": reply_option,
26
27
  })
27
28
 
28
- def create_post_with_media(self, *, auth_token: str, text: str, media: list[Any], proxy: str, disable_link_preview: Optional[bool] = None) -> ActionResponse:
29
- """Create a tweet with media attachments."""
29
+ def create_post_with_media(self, *, auth_token: str, text: str, media: list[TweetMediaInput], proxy: str, disable_link_preview: Optional[bool] = None, reply_option: Optional[ReplyOption] = None) -> ActionResponse:
30
+ """Create a tweet with media attachments.
31
+
32
+ ``media`` accepts items shaped as ``{"media_id": str}``,
33
+ ``{"url": str, "type": str | None}``, or ``{"data": str, "type": str}``.
34
+ """
30
35
  return self._client._post("/tw-v2/interaction/create-post-with-media", {
31
36
  "authToken": auth_token, "text": text, "media": media,
32
37
  "proxy": proxy, "disableLinkPreview": disable_link_preview,
38
+ "replyOption": reply_option,
33
39
  })
34
40
 
35
41
  def reply_post(self, *, auth_token: str, text: str, tweet_id: str, proxy: str, disable_link_preview: Optional[bool] = None) -> ActionResponse:
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional, TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ from ..client import TweetAPI
7
+ from ..types import ProfileMediaInput, UserResponse
8
+
9
+
10
+ class ProfileResource:
11
+ def __init__(self, client: TweetAPI) -> None:
12
+ self._client = client
13
+
14
+ def update(self, *, auth_token: str, name: Optional[str] = None, bio: Optional[str] = None, location: Optional[str] = None, website: Optional[str] = None, proxy: Optional[str] = None) -> UserResponse:
15
+ """Update the authenticated user's profile."""
16
+ return self._client._post("/tw-v2/profile/update", {
17
+ "authToken": auth_token, "name": name, "bio": bio,
18
+ "location": location, "website": website, "proxy": proxy,
19
+ })
20
+
21
+ def avatar(self, *, auth_token: str, media: ProfileMediaInput, proxy: Optional[str] = None) -> UserResponse:
22
+ """Update the authenticated user's avatar."""
23
+ return self._client._post("/tw-v2/profile/avatar", {
24
+ "authToken": auth_token, "media": media, "proxy": proxy,
25
+ })
26
+
27
+ def banner(self, *, auth_token: str, media: ProfileMediaInput, proxy: Optional[str] = None) -> UserResponse:
28
+ """Update the authenticated user's banner."""
29
+ return self._client._post("/tw-v2/profile/banner", {
30
+ "authToken": auth_token, "media": media, "proxy": proxy,
31
+ })
@@ -6,7 +6,7 @@ All types mirror the API's JSON response shapes exactly.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- from typing import Any, List as TypingList, Optional, TypedDict
9
+ from typing import Any, List as TypingList, Optional, TypedDict, Union
10
10
 
11
11
 
12
12
  # ─── Response Wrappers ───────────────────────────────────────────────────────
@@ -146,6 +146,42 @@ class UserAnalytics(TypedDict, total=False):
146
146
  # ─── Media ───────────────────────────────────────────────────────────────────
147
147
 
148
148
 
149
+ class TweetMediaIdInput(TypedDict):
150
+ media_id: str
151
+
152
+
153
+ class TweetMediaUrlInputRequired(TypedDict):
154
+ url: str
155
+
156
+
157
+ class TweetMediaUrlInput(TweetMediaUrlInputRequired, total=False):
158
+ type: str
159
+
160
+
161
+ class TweetMediaDataInput(TypedDict):
162
+ data: str
163
+ type: str
164
+
165
+
166
+ TweetMediaInput = Union[TweetMediaIdInput, TweetMediaUrlInput, TweetMediaDataInput]
167
+
168
+
169
+ class ProfileMediaUrlInputRequired(TypedDict):
170
+ url: str
171
+
172
+
173
+ class ProfileMediaUrlInput(ProfileMediaUrlInputRequired, total=False):
174
+ type: str
175
+
176
+
177
+ class ProfileMediaDataInput(TypedDict):
178
+ data: str
179
+ type: str
180
+
181
+
182
+ ProfileMediaInput = Union[ProfileMediaUrlInput, ProfileMediaDataInput]
183
+
184
+
149
185
  class MediaSize(TypedDict, total=False):
150
186
  width: int
151
187
  height: int
@@ -280,6 +316,27 @@ class ConversationControl(TypedDict, total=False):
280
316
  allowedUserIds: list[str]
281
317
 
282
318
 
319
+ class ReplyOptionRequired(TypedDict):
320
+ mode: str
321
+
322
+
323
+ class ReplyOption(ReplyOptionRequired, total=False):
324
+ regions: list[str]
325
+
326
+
327
+ class ReactionTargetAuthor(TypedDict, total=False):
328
+ id: str
329
+ username: str
330
+ name: str
331
+
332
+
333
+ class ReactionContext(TypedDict, total=False):
334
+ type: str
335
+ targetTweetId: str
336
+ targetTweetUrl: str
337
+ targetAuthor: ReactionTargetAuthor
338
+
339
+
283
340
  class Tweet(TypedDict, total=False):
284
341
  id: str
285
342
  conversationId: Optional[str]
@@ -316,6 +373,7 @@ class Tweet(TypedDict, total=False):
316
373
  hasBirdwatchNotes: bool
317
374
  birdwatchPivot: Optional[BirdwatchPivot]
318
375
  conversationControl: Optional[ConversationControl]
376
+ reactionContext: Optional[ReactionContext]
319
377
  isPromoted: bool
320
378
  communityId: Optional[str]
321
379
  createdAt: Optional[str]
@@ -1,3 +0,0 @@
1
- {
2
- "includeCoAuthoredBy": false
3
- }
@@ -1,37 +0,0 @@
1
- from tweetapi import TweetAPI, AuthenticationError, TweetAPIError
2
-
3
- client = TweetAPI(api_key="YOUR_API_KEY")
4
-
5
- try:
6
- # First, log in to get an auth token
7
- login_result = client.auth.login(
8
- username="your_twitter_username",
9
- password="your_twitter_password",
10
- proxy="hostname:port@username:password",
11
- )
12
-
13
- auth_token = login_result["data"]["cookies"]["auth_token"]
14
- print(f'Logged in as @{login_result["data"]["user"]["username"]}')
15
-
16
- # Create a tweet
17
- post = client.post.create_post(
18
- auth_token=auth_token,
19
- text="Hello from TweetAPI! 🚀",
20
- proxy="hostname:port@username:password",
21
- )
22
-
23
- print(f'Tweet created: {post["data"]["metadata"]["tweet_id"]}')
24
- print(f'URL: {post["data"]["metadata"]["url"]}')
25
-
26
- # Like the tweet we just created
27
- client.interaction.favorite_post(
28
- auth_token=auth_token,
29
- tweet_id=post["data"]["id"],
30
- proxy="hostname:port@username:password",
31
- )
32
- print("Liked the tweet!")
33
-
34
- except AuthenticationError:
35
- print("Authentication failed. Check your credentials.")
36
- except TweetAPIError as e:
37
- print(f"API error [{e.code}]: {e.message}")
@@ -1,31 +0,0 @@
1
- from tweetapi import TweetAPI, NotFoundError, RateLimitError, TweetAPIError
2
-
3
- client = TweetAPI(api_key="YOUR_API_KEY")
4
-
5
- try:
6
- # Get a single user by username
7
- user = client.user.get_by_username(username="elonmusk")
8
- data = user["data"]
9
- print(f'{data["name"]} (@{data["username"]})')
10
- print(f'Followers: {data["followerCount"]:,}')
11
- print(f'Tweets: {data["tweetCount"]:,}')
12
- print(f'Verified: {data["isBlueVerified"]}')
13
-
14
- # Get first page of followers
15
- followers = client.user.get_followers(user_id=data["id"])
16
- print(f"\nFirst {len(followers['data'])} followers:")
17
- for follower in followers["data"][:5]:
18
- print(f' - @{follower["username"]} ({follower["followerCount"]} followers)')
19
-
20
- # Paginate to next page
21
- next_cursor = followers["pagination"]["nextCursor"]
22
- if next_cursor:
23
- next_page = client.user.get_followers(user_id=data["id"], cursor=next_cursor)
24
- print(f"\nNext page: {len(next_page['data'])} more followers")
25
-
26
- except NotFoundError:
27
- print("User not found")
28
- except RateLimitError as e:
29
- print(f"Rate limited. Retry in {e.retry_after} seconds")
30
- except TweetAPIError as e:
31
- print(f"API error [{e.code}]: {e.message}")
@@ -1,20 +0,0 @@
1
- from tweetapi import TweetAPI
2
-
3
- client = TweetAPI(api_key="YOUR_API_KEY")
4
-
5
- # Search for latest tweets about Bitcoin
6
- results = client.explore.search(query="bitcoin", type="Latest")
7
-
8
- print(f'Found {results["meta"]["resultCount"]} results in {results["meta"]["completedIn"]}ms\n')
9
-
10
- for item in results["data"]:
11
- if "text" in item and "author" in item:
12
- author = item["author"]
13
- print(f'@{author["username"]}: {item["text"][:100]}')
14
- print(f' ♥ {item["likeCount"]} 🔁 {item["retweetCount"]} 💬 {item["replyCount"]}\n')
15
-
16
- # Paginate if more results exist
17
- next_cursor = results["pagination"]["nextCursor"]
18
- if next_cursor:
19
- print(f'More results available. Use cursor to paginate:')
20
- print(f' cursor="{next_cursor}"')
@@ -1,247 +0,0 @@
1
- import json
2
-
3
- import pytest
4
- import responses
5
-
6
- from tweetapi import (
7
- TweetAPI,
8
- TweetAPIError,
9
- AuthenticationError,
10
- ValidationError,
11
- NotFoundError,
12
- RateLimitError,
13
- ServerError,
14
- NetworkError,
15
- )
16
-
17
- BASE_URL = "https://api.tweetapi.com"
18
-
19
-
20
- def make_client(**kwargs) -> TweetAPI:
21
- defaults = {"api_key": "test-api-key", "max_retries": 0}
22
- defaults.update(kwargs)
23
- return TweetAPI(**defaults)
24
-
25
-
26
- class TestClientInit:
27
- def test_raises_on_empty_api_key(self):
28
- with pytest.raises(ValueError, match="api_key is required"):
29
- TweetAPI(api_key="")
30
-
31
- def test_creates_with_valid_key(self):
32
- client = TweetAPI(api_key="key")
33
- assert client is not None
34
-
35
- def test_all_resources_exist(self):
36
- client = make_client()
37
- for attr in ["user", "tweet", "post", "interaction", "list", "community",
38
- "space", "explore", "auth", "xchat", "dm"]:
39
- assert hasattr(client, attr), f"Missing resource: {attr}"
40
-
41
-
42
- class TestGetRequests:
43
- @responses.activate
44
- def test_sends_api_key_header(self):
45
- responses.add(
46
- responses.GET,
47
- f"{BASE_URL}/tw-v2/user/by-username",
48
- json={"data": {"id": "123", "username": "testuser", "name": "Test"}},
49
- status=200,
50
- )
51
-
52
- client = make_client()
53
- client.user.get_by_username(username="testuser")
54
-
55
- assert len(responses.calls) == 1
56
- assert responses.calls[0].request.headers["X-API-Key"] == "test-api-key"
57
-
58
- @responses.activate
59
- def test_sends_query_params(self):
60
- responses.add(
61
- responses.GET,
62
- f"{BASE_URL}/tw-v2/user/by-username",
63
- json={"data": {"id": "123", "username": "elonmusk"}},
64
- status=200,
65
- )
66
-
67
- client = make_client()
68
- client.user.get_by_username(username="elonmusk")
69
-
70
- assert "username=elonmusk" in responses.calls[0].request.url
71
-
72
- @responses.activate
73
- def test_omits_none_params(self):
74
- responses.add(
75
- responses.GET,
76
- f"{BASE_URL}/tw-v2/user/followers",
77
- json={"data": [], "pagination": {"nextCursor": None, "prevCursor": None}},
78
- status=200,
79
- )
80
-
81
- client = make_client()
82
- client.user.get_followers(user_id="123")
83
-
84
- assert "cursor" not in responses.calls[0].request.url
85
- assert "userId=123" in responses.calls[0].request.url
86
-
87
- @responses.activate
88
- def test_parses_json_response(self):
89
- responses.add(
90
- responses.GET,
91
- f"{BASE_URL}/tw-v2/user/by-username",
92
- json={"data": {"id": "123", "username": "elonmusk", "followerCount": 180000000}},
93
- status=200,
94
- )
95
-
96
- client = make_client()
97
- result = client.user.get_by_username(username="elonmusk")
98
-
99
- assert result["data"]["username"] == "elonmusk"
100
- assert result["data"]["followerCount"] == 180000000
101
-
102
-
103
- class TestPostRequests:
104
- @responses.activate
105
- def test_sends_json_body(self):
106
- responses.add(
107
- responses.POST,
108
- f"{BASE_URL}/tw-v2/interaction/like-post",
109
- json={"data": {"id": "789", "action": "like", "timestamp": "2025-01-01", "success": True}},
110
- status=200,
111
- )
112
-
113
- client = make_client()
114
- client.interaction.favorite_post(auth_token="auth123", tweet_id="789", proxy="h:p@u:p")
115
-
116
- body = json.loads(responses.calls[0].request.body)
117
- assert body["authToken"] == "auth123"
118
- assert body["tweetId"] == "789"
119
-
120
- @responses.activate
121
- def test_strips_none_from_body(self):
122
- responses.add(
123
- responses.POST,
124
- f"{BASE_URL}/tw-v2/interaction/create-post",
125
- json={"data": {"id": "1", "action": "create_tweet", "success": True}},
126
- status=200,
127
- )
128
-
129
- client = make_client()
130
- client.post.create_post(auth_token="auth", text="Hello", proxy="h:p@u:p")
131
-
132
- body = json.loads(responses.calls[0].request.body)
133
- assert "disableLinkPreview" not in body
134
-
135
-
136
- class TestErrorHandling:
137
- @responses.activate
138
- def test_validation_error_on_400(self):
139
- responses.add(
140
- responses.GET,
141
- f"{BASE_URL}/tw-v2/user/by-username",
142
- json={"error": {"code": "VALIDATION_ERROR", "message": "Required - username", "details": None}},
143
- status=400,
144
- )
145
-
146
- client = make_client()
147
- with pytest.raises(ValidationError) as exc_info:
148
- client.user.get_by_username(username="")
149
-
150
- assert exc_info.value.code == "VALIDATION_ERROR"
151
- assert exc_info.value.status_code == 400
152
-
153
- @responses.activate
154
- def test_authentication_error_on_401(self):
155
- responses.add(
156
- responses.GET,
157
- f"{BASE_URL}/tw-v2/user/by-username",
158
- json={"error": {"code": "UNAUTHORIZED", "message": "Invalid token", "details": None}},
159
- status=401,
160
- )
161
-
162
- client = make_client()
163
- with pytest.raises(AuthenticationError) as exc_info:
164
- client.user.get_by_username(username="test")
165
-
166
- assert exc_info.value.code == "UNAUTHORIZED"
167
-
168
- @responses.activate
169
- def test_not_found_error_on_404(self):
170
- responses.add(
171
- responses.GET,
172
- f"{BASE_URL}/tw-v2/user/by-username",
173
- json={"error": {"code": "NOT_FOUND", "message": "User not found", "details": None}},
174
- status=404,
175
- )
176
-
177
- client = make_client()
178
- with pytest.raises(NotFoundError) as exc_info:
179
- client.user.get_by_username(username="nonexistent")
180
-
181
- assert exc_info.value.code == "NOT_FOUND"
182
- assert exc_info.value.status_code == 404
183
- assert exc_info.value.message == "User not found"
184
-
185
- @responses.activate
186
- def test_rate_limit_error_with_retry_after(self):
187
- responses.add(
188
- responses.GET,
189
- f"{BASE_URL}/tw-v2/user/by-username",
190
- json={"error": {"code": "RATE_LIMIT", "message": "Rate limit exceeded", "details": {"retryAfter": 30}}},
191
- status=429,
192
- )
193
-
194
- client = make_client()
195
- with pytest.raises(RateLimitError) as exc_info:
196
- client.user.get_by_username(username="test")
197
-
198
- assert exc_info.value.retry_after == 30
199
-
200
- @responses.activate
201
- def test_server_error_on_500(self):
202
- responses.add(
203
- responses.GET,
204
- f"{BASE_URL}/tw-v2/user/by-username",
205
- json={"error": {"code": "INTERNAL_SERVER_ERROR", "message": "Something went wrong", "details": None}},
206
- status=500,
207
- )
208
-
209
- client = make_client()
210
- with pytest.raises(ServerError):
211
- client.user.get_by_username(username="test")
212
-
213
- @responses.activate
214
- def test_malformed_error_response(self):
215
- responses.add(
216
- responses.GET,
217
- f"{BASE_URL}/tw-v2/user/by-username",
218
- body="not json",
219
- status=500,
220
- content_type="text/plain",
221
- )
222
-
223
- client = make_client()
224
- with pytest.raises(ServerError) as exc_info:
225
- client.user.get_by_username(username="test")
226
-
227
- assert exc_info.value.code == "UNKNOWN_ERROR"
228
-
229
- @responses.activate
230
- def test_preserves_api_error_code(self):
231
- responses.add(
232
- responses.GET,
233
- f"{BASE_URL}/tw-v2/user/by-username",
234
- json={"error": {"code": "ACCOUNT_SUSPENDED", "message": "Account suspended", "details": None}},
235
- status=403,
236
- )
237
-
238
- client = make_client()
239
- with pytest.raises(TweetAPIError) as exc_info:
240
- client.user.get_by_username(username="suspended")
241
-
242
- assert exc_info.value.code == "ACCOUNT_SUSPENDED"
243
-
244
- def test_network_error_on_connection_failure(self):
245
- client = TweetAPI(api_key="key", base_url="http://localhost:1", max_retries=0)
246
- with pytest.raises(NetworkError):
247
- client.user.get_by_username(username="test")
@@ -1,101 +0,0 @@
1
- """Tests for auto-pagination helpers."""
2
-
3
- import pytest
4
-
5
- from tweetapi import paginate, paginate_pages
6
-
7
-
8
- def make_fetcher(pages):
9
- """Create a fetcher that returns pre-built pages in sequence."""
10
- call_count = [0]
11
-
12
- def fetcher(cursor=None):
13
- idx = call_count[0]
14
- if idx >= len(pages):
15
- raise RuntimeError(f"Unexpected call #{idx + 1}")
16
- call_count[0] += 1
17
- return pages[idx]
18
-
19
- fetcher.call_count = call_count
20
- return fetcher
21
-
22
-
23
- class TestPaginatePages:
24
- def test_iterates_all_pages(self):
25
- pages_data = [
26
- {"data": [{"id": "1"}, {"id": "2"}], "pagination": {"nextCursor": "c2", "prevCursor": None}},
27
- {"data": [{"id": "3"}], "pagination": {"nextCursor": "c3", "prevCursor": "c2"}},
28
- {"data": [{"id": "4"}], "pagination": {"nextCursor": None, "prevCursor": "c3"}},
29
- ]
30
- fetcher = make_fetcher(pages_data)
31
-
32
- result = list(paginate_pages(fetcher))
33
- assert len(result) == 3
34
- assert fetcher.call_count[0] == 3
35
-
36
- def test_respects_max_pages(self):
37
- pages_data = [
38
- {"data": [{"id": "1"}], "pagination": {"nextCursor": "c2", "prevCursor": None}},
39
- {"data": [{"id": "2"}], "pagination": {"nextCursor": "c3", "prevCursor": "c2"}},
40
- {"data": [{"id": "3"}], "pagination": {"nextCursor": None, "prevCursor": "c3"}},
41
- ]
42
- fetcher = make_fetcher(pages_data)
43
-
44
- result = list(paginate_pages(fetcher, max_pages=2))
45
- assert len(result) == 2
46
- assert fetcher.call_count[0] == 2
47
-
48
- def test_single_page_no_next_cursor(self):
49
- pages_data = [
50
- {"data": [{"id": "1"}], "pagination": {"nextCursor": None, "prevCursor": None}},
51
- ]
52
- fetcher = make_fetcher(pages_data)
53
-
54
- result = list(paginate_pages(fetcher))
55
- assert len(result) == 1
56
-
57
- def test_empty_data(self):
58
- pages_data = [
59
- {"data": [], "pagination": {"nextCursor": None, "prevCursor": None}},
60
- ]
61
- fetcher = make_fetcher(pages_data)
62
-
63
- result = list(paginate_pages(fetcher))
64
- assert len(result) == 1
65
- assert result[0]["data"] == []
66
-
67
- def test_error_propagation(self):
68
- call_count = [0]
69
-
70
- def fetcher(cursor=None):
71
- idx = call_count[0]
72
- call_count[0] += 1
73
- if idx == 0:
74
- return {"data": [{"id": "1"}], "pagination": {"nextCursor": "c2", "prevCursor": None}}
75
- raise RuntimeError("API error")
76
-
77
- with pytest.raises(RuntimeError, match="API error"):
78
- list(paginate_pages(fetcher))
79
-
80
-
81
- class TestPaginate:
82
- def test_yields_individual_items(self):
83
- pages_data = [
84
- {"data": [{"id": "1"}, {"id": "2"}], "pagination": {"nextCursor": "c2", "prevCursor": None}},
85
- {"data": [{"id": "3"}], "pagination": {"nextCursor": None, "prevCursor": "c2"}},
86
- ]
87
- fetcher = make_fetcher(pages_data)
88
-
89
- items = list(paginate(fetcher))
90
- assert items == [{"id": "1"}, {"id": "2"}, {"id": "3"}]
91
-
92
- def test_respects_max_pages(self):
93
- pages_data = [
94
- {"data": [{"id": "1"}], "pagination": {"nextCursor": "c2", "prevCursor": None}},
95
- {"data": [{"id": "2"}], "pagination": {"nextCursor": None, "prevCursor": "c2"}},
96
- ]
97
- fetcher = make_fetcher(pages_data)
98
-
99
- items = list(paginate(fetcher, max_pages=1))
100
- assert items == [{"id": "1"}]
101
- assert fetcher.call_count[0] == 1
@@ -1,148 +0,0 @@
1
- """Tests for retry with exponential backoff."""
2
-
3
- from unittest.mock import patch
4
-
5
- import pytest
6
- import responses
7
-
8
- from tweetapi import (
9
- TweetAPI,
10
- RateLimitError,
11
- ServerError,
12
- NetworkError,
13
- ValidationError,
14
- AuthenticationError,
15
- NotFoundError,
16
- )
17
-
18
- BASE_URL = "https://api.tweetapi.com"
19
-
20
-
21
- def make_client(**kwargs):
22
- defaults = {"api_key": "test-key", "initial_retry_delay": 0.01, "max_retry_delay": 0.1}
23
- defaults.update(kwargs)
24
- return TweetAPI(**defaults)
25
-
26
-
27
- class TestRetryOnTransientErrors:
28
- @responses.activate
29
- def test_retries_on_500_and_succeeds(self):
30
- responses.add(responses.GET, f"{BASE_URL}/tw-v2/user/by-username",
31
- json={"error": {"code": "SERVER_ERROR", "message": "fail", "details": None}}, status=500)
32
- responses.add(responses.GET, f"{BASE_URL}/tw-v2/user/by-username",
33
- json={"data": {"id": "1", "username": "test"}}, status=200)
34
-
35
- client = make_client(max_retries=3)
36
- result = client.user.get_by_username(username="test")
37
- assert result["data"]["id"] == "1"
38
- assert len(responses.calls) == 2
39
-
40
- @responses.activate
41
- def test_retries_on_429_with_retry_after(self):
42
- responses.add(responses.GET, f"{BASE_URL}/tw-v2/user/by-username",
43
- json={"error": {"code": "RATE_LIMIT", "message": "rate limited",
44
- "details": {"retryAfter": 1}}}, status=429)
45
- responses.add(responses.GET, f"{BASE_URL}/tw-v2/user/by-username",
46
- json={"data": {"id": "1"}}, status=200)
47
-
48
- client = make_client(max_retries=1, max_retry_delay=2.0)
49
- with patch("tweetapi.client.time.sleep") as mock_sleep:
50
- result = client.user.get_by_username(username="test")
51
- assert result["data"]["id"] == "1"
52
- assert len(responses.calls) == 2
53
- # Should have slept for the retryAfter duration (1 second)
54
- mock_sleep.assert_called_once()
55
- delay = mock_sleep.call_args[0][0]
56
- assert 0.9 <= delay <= 2.0 # retryAfter=1, capped at max_retry_delay
57
-
58
- @responses.activate
59
- def test_exhausts_retries_and_raises(self):
60
- for _ in range(4):
61
- responses.add(responses.GET, f"{BASE_URL}/tw-v2/user/by-username",
62
- json={"error": {"code": "SERVER_ERROR", "message": "fail", "details": None}}, status=500)
63
-
64
- client = make_client(max_retries=2)
65
- with pytest.raises(ServerError):
66
- client.user.get_by_username(username="test")
67
- assert len(responses.calls) == 3 # 1 initial + 2 retries
68
-
69
-
70
- class TestNoRetryOnClientErrors:
71
- @responses.activate
72
- def test_no_retry_on_400(self):
73
- responses.add(responses.GET, f"{BASE_URL}/tw-v2/user/by-username",
74
- json={"error": {"code": "BAD_REQUEST", "message": "bad", "details": None}}, status=400)
75
-
76
- client = make_client(max_retries=3)
77
- with pytest.raises(ValidationError):
78
- client.user.get_by_username(username="test")
79
- assert len(responses.calls) == 1
80
-
81
- @responses.activate
82
- def test_no_retry_on_401(self):
83
- responses.add(responses.GET, f"{BASE_URL}/tw-v2/user/by-username",
84
- json={"error": {"code": "UNAUTHORIZED", "message": "bad key", "details": None}}, status=401)
85
-
86
- client = make_client(max_retries=3)
87
- with pytest.raises(AuthenticationError):
88
- client.user.get_by_username(username="test")
89
- assert len(responses.calls) == 1
90
-
91
- @responses.activate
92
- def test_no_retry_on_404(self):
93
- responses.add(responses.GET, f"{BASE_URL}/tw-v2/user/by-username",
94
- json={"error": {"code": "NOT_FOUND", "message": "nope", "details": None}}, status=404)
95
-
96
- client = make_client(max_retries=3)
97
- with pytest.raises(NotFoundError):
98
- client.user.get_by_username(username="test")
99
- assert len(responses.calls) == 1
100
-
101
-
102
- class TestRetryOnNetworkErrors:
103
- @responses.activate
104
- def test_retries_on_connection_error(self):
105
- import requests as req
106
- responses.add(responses.GET, f"{BASE_URL}/tw-v2/user/by-username",
107
- body=req.exceptions.ConnectionError("connection refused"))
108
- responses.add(responses.GET, f"{BASE_URL}/tw-v2/user/by-username",
109
- json={"data": {"id": "1"}}, status=200)
110
-
111
- client = make_client(max_retries=1)
112
- result = client.user.get_by_username(username="test")
113
- assert result["data"]["id"] == "1"
114
- assert len(responses.calls) == 2
115
-
116
-
117
- class TestRetryDisabled:
118
- @responses.activate
119
- def test_max_retries_zero(self):
120
- responses.add(responses.GET, f"{BASE_URL}/tw-v2/user/by-username",
121
- json={"error": {"code": "SERVER_ERROR", "message": "fail", "details": None}}, status=500)
122
-
123
- client = make_client(max_retries=0)
124
- with pytest.raises(ServerError):
125
- client.user.get_by_username(username="test")
126
- assert len(responses.calls) == 1
127
-
128
-
129
- class TestRateLimitInfo:
130
- @responses.activate
131
- def test_rate_limit_info_initially_none(self):
132
- client = make_client()
133
- assert client.rate_limit_info is None
134
-
135
- @responses.activate
136
- def test_rate_limit_info_populated_after_429(self):
137
- responses.add(responses.GET, f"{BASE_URL}/tw-v2/user/by-username",
138
- json={"error": {"code": "RATE_LIMIT", "message": "rate limited",
139
- "details": {"retryAfter": 30}}}, status=429)
140
- responses.add(responses.GET, f"{BASE_URL}/tw-v2/user/by-username",
141
- json={"data": {"id": "1"}}, status=200)
142
-
143
- client = make_client(max_retries=1)
144
- client.user.get_by_username(username="test")
145
-
146
- assert client.rate_limit_info is not None
147
- assert client.rate_limit_info["retry_after"] == 30
148
- assert client.rate_limit_info["timestamp"] > 0
@@ -1,56 +0,0 @@
1
- """Tests for split timeout configuration."""
2
-
3
- import responses
4
-
5
- from tweetapi import TweetAPI
6
-
7
- BASE_URL = "https://api.tweetapi.com"
8
-
9
-
10
- class TestTimeoutDefaults:
11
- def test_default_timeout_is_tuple(self):
12
- client = TweetAPI(api_key="key")
13
- assert client._timeout == (10.0, 30.0)
14
-
15
- def test_single_timeout_value(self):
16
- client = TweetAPI(api_key="key", timeout=15)
17
- assert client._timeout == 15
18
-
19
- def test_tuple_timeout_value(self):
20
- client = TweetAPI(api_key="key", timeout=(5, 20))
21
- assert client._timeout == (5, 20)
22
-
23
- def test_explicit_connect_and_read(self):
24
- client = TweetAPI(api_key="key", connect_timeout=5.0, read_timeout=60.0)
25
- assert client._timeout == (5.0, 60.0)
26
-
27
- def test_only_connect_timeout(self):
28
- client = TweetAPI(api_key="key", connect_timeout=3.0)
29
- assert client._timeout == (3.0, 30.0)
30
-
31
- def test_only_read_timeout(self):
32
- client = TweetAPI(api_key="key", read_timeout=120.0)
33
- assert client._timeout == (10.0, 120.0)
34
-
35
- def test_timeout_overrides_connect_read(self):
36
- """When timeout is explicitly set, connect_timeout/read_timeout are ignored."""
37
- client = TweetAPI(api_key="key", timeout=25, connect_timeout=5, read_timeout=60)
38
- assert client._timeout == 25
39
-
40
-
41
- class TestTimeoutPassedToRequests:
42
- @responses.activate
43
- def test_timeout_passed_in_request(self):
44
- responses.add(
45
- responses.GET,
46
- f"{BASE_URL}/tw-v2/user/by-username",
47
- json={"data": {"id": "1", "username": "test"}},
48
- status=200,
49
- )
50
-
51
- client = TweetAPI(api_key="key", timeout=42)
52
- client.user.get_by_username(username="test")
53
-
54
- # The responses library records the request but doesn't expose the
55
- # timeout kwarg. We verify configuration storage instead.
56
- assert client._timeout == 42
File without changes
File without changes
File without changes