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.
- {tweetapi-1.1.0 → tweetapi-2.0.0}/.gitignore +2 -0
- {tweetapi-1.1.0 → tweetapi-2.0.0}/PKG-INFO +91 -5
- {tweetapi-1.1.0 → tweetapi-2.0.0}/README.md +90 -4
- {tweetapi-1.1.0 → tweetapi-2.0.0}/pyproject.toml +8 -1
- {tweetapi-1.1.0 → tweetapi-2.0.0}/tweetapi/__init__.py +1 -1
- {tweetapi-1.1.0 → tweetapi-2.0.0}/tweetapi/client.py +2 -0
- {tweetapi-1.1.0 → tweetapi-2.0.0}/tweetapi/resources/auth.py +4 -3
- {tweetapi-1.1.0 → tweetapi-2.0.0}/tweetapi/resources/community.py +19 -2
- {tweetapi-1.1.0 → tweetapi-2.0.0}/tweetapi/resources/list_.py +23 -1
- {tweetapi-1.1.0 → tweetapi-2.0.0}/tweetapi/resources/post.py +12 -6
- tweetapi-2.0.0/tweetapi/resources/profile.py +31 -0
- {tweetapi-1.1.0 → tweetapi-2.0.0}/tweetapi/types.py +59 -1
- tweetapi-1.1.0/.claude/settings.local.json +0 -3
- tweetapi-1.1.0/examples/create_post.py +0 -37
- tweetapi-1.1.0/examples/get_user.py +0 -31
- tweetapi-1.1.0/examples/search_tweets.py +0 -20
- tweetapi-1.1.0/tests/test_client.py +0 -247
- tweetapi-1.1.0/tests/test_pagination.py +0 -101
- tweetapi-1.1.0/tests/test_retry.py +0 -148
- tweetapi-1.1.0/tests/test_timeout.py +0 -56
- tweetapi-1.1.0/tweetapi/resources/__init__.py +0 -0
- {tweetapi-1.1.0 → tweetapi-2.0.0}/tweetapi/errors.py +0 -0
- {tweetapi-1.1.0 → tweetapi-2.0.0}/tweetapi/pagination.py +0 -0
- {tweetapi-1.1.0 → tweetapi-2.0.0}/tweetapi/py.typed +0 -0
- {tweetapi-1.1.0/tests → tweetapi-2.0.0/tweetapi/resources}/__init__.py +0 -0
- {tweetapi-1.1.0 → tweetapi-2.0.0}/tweetapi/resources/explore.py +0 -0
- {tweetapi-1.1.0 → tweetapi-2.0.0}/tweetapi/resources/interaction.py +0 -0
- {tweetapi-1.1.0 → tweetapi-2.0.0}/tweetapi/resources/space.py +0 -0
- {tweetapi-1.1.0 → tweetapi-2.0.0}/tweetapi/resources/tweet.py +0 -0
- {tweetapi-1.1.0 → tweetapi-2.0.0}/tweetapi/resources/unencrypted_dm.py +0 -0
- {tweetapi-1.1.0 → tweetapi-2.0.0}/tweetapi/resources/user.py +0 -0
- {tweetapi-1.1.0 → tweetapi-2.0.0}/tweetapi/resources/xchat.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tweetapi
|
|
3
|
-
Version:
|
|
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 = "
|
|
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
|
|
|
@@ -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
|
|
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, "
|
|
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
|
|
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[
|
|
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
|
|
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[
|
|
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,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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|