tweetapi 1.2.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 (33) hide show
  1. {tweetapi-1.2.0 → tweetapi-2.0.0}/.gitignore +2 -0
  2. {tweetapi-1.2.0 → tweetapi-2.0.0}/PKG-INFO +4 -2
  3. {tweetapi-1.2.0 → tweetapi-2.0.0}/README.md +3 -1
  4. {tweetapi-1.2.0 → tweetapi-2.0.0}/pyproject.toml +8 -1
  5. {tweetapi-1.2.0 → tweetapi-2.0.0}/tweetapi/__init__.py +1 -1
  6. {tweetapi-1.2.0 → tweetapi-2.0.0}/tweetapi/resources/auth.py +4 -3
  7. tweetapi-1.2.0/.claude/settings.local.json +0 -3
  8. tweetapi-1.2.0/AGENTS.md +0 -92
  9. tweetapi-1.2.0/examples/create_post.py +0 -37
  10. tweetapi-1.2.0/examples/get_user.py +0 -31
  11. tweetapi-1.2.0/examples/search_tweets.py +0 -20
  12. tweetapi-1.2.0/tests/test_client.py +0 -492
  13. tweetapi-1.2.0/tests/test_pagination.py +0 -101
  14. tweetapi-1.2.0/tests/test_retry.py +0 -148
  15. tweetapi-1.2.0/tests/test_timeout.py +0 -56
  16. tweetapi-1.2.0/tweetapi/resources/__init__.py +0 -0
  17. {tweetapi-1.2.0 → tweetapi-2.0.0}/tweetapi/client.py +0 -0
  18. {tweetapi-1.2.0 → tweetapi-2.0.0}/tweetapi/errors.py +0 -0
  19. {tweetapi-1.2.0 → tweetapi-2.0.0}/tweetapi/pagination.py +0 -0
  20. {tweetapi-1.2.0 → tweetapi-2.0.0}/tweetapi/py.typed +0 -0
  21. {tweetapi-1.2.0/tests → tweetapi-2.0.0/tweetapi/resources}/__init__.py +0 -0
  22. {tweetapi-1.2.0 → tweetapi-2.0.0}/tweetapi/resources/community.py +0 -0
  23. {tweetapi-1.2.0 → tweetapi-2.0.0}/tweetapi/resources/explore.py +0 -0
  24. {tweetapi-1.2.0 → tweetapi-2.0.0}/tweetapi/resources/interaction.py +0 -0
  25. {tweetapi-1.2.0 → tweetapi-2.0.0}/tweetapi/resources/list_.py +0 -0
  26. {tweetapi-1.2.0 → tweetapi-2.0.0}/tweetapi/resources/post.py +0 -0
  27. {tweetapi-1.2.0 → tweetapi-2.0.0}/tweetapi/resources/profile.py +0 -0
  28. {tweetapi-1.2.0 → tweetapi-2.0.0}/tweetapi/resources/space.py +0 -0
  29. {tweetapi-1.2.0 → tweetapi-2.0.0}/tweetapi/resources/tweet.py +0 -0
  30. {tweetapi-1.2.0 → tweetapi-2.0.0}/tweetapi/resources/unencrypted_dm.py +0 -0
  31. {tweetapi-1.2.0 → tweetapi-2.0.0}/tweetapi/resources/user.py +0 -0
  32. {tweetapi-1.2.0 → tweetapi-2.0.0}/tweetapi/resources/xchat.py +0 -0
  33. {tweetapi-1.2.0 → tweetapi-2.0.0}/tweetapi/types.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.2.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
@@ -191,7 +191,9 @@ next_page = client.user.get_followers(
191
191
 
192
192
  | Method | Description |
193
193
  |--------|-------------|
194
- | `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.
195
197
 
196
198
  ### X Chat (Encrypted DMs)
197
199
 
@@ -161,7 +161,9 @@ next_page = client.user.get_followers(
161
161
 
162
162
  | Method | Description |
163
163
  |--------|-------------|
164
- | `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.
165
167
 
166
168
  ### X Chat (Encrypted DMs)
167
169
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "tweetapi"
7
- version = "1.2.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.2.0"
40
+ __version__ = "2.0.0"
@@ -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,3 +0,0 @@
1
- {
2
- "includeCoAuthoredBy": false
3
- }
tweetapi-1.2.0/AGENTS.md DELETED
@@ -1,92 +0,0 @@
1
- # TweetAPI Python SDK Agent Notes
2
-
3
- ## Repository Context
4
-
5
- This repository is the official Python SDK for TweetAPI.
6
-
7
- - GitHub: https://github.com/tweetapi/python
8
- - PyPI package: https://pypi.org/project/tweetapi/
9
- - Import package: `tweetapi`
10
- - Distribution package: `tweetapi`
11
-
12
- This repo is not a deployed web service. Customer impact happens when changes are
13
- merged to the public GitHub repository and, separately, when a new package
14
- version is built and uploaded to PyPI.
15
-
16
- ## Package And Release Model
17
-
18
- Package metadata lives in `pyproject.toml`. The package uses Hatchling:
19
-
20
- ```toml
21
- [build-system]
22
- requires = ["hatchling"]
23
- build-backend = "hatchling.build"
24
- ```
25
-
26
- When preparing a release:
27
-
28
- 1. Update the version in `pyproject.toml`.
29
- 2. Update `tweetapi/__init__.py` so `__version__` matches.
30
- 3. Run tests and compile checks.
31
- 4. Build distributions with `python -m build`.
32
- 5. Inspect the generated `dist/` contents.
33
- 6. Upload to PyPI only when explicitly requested by the maintainer.
34
-
35
- Do not publish to PyPI from an agent run unless the user explicitly asks for it.
36
- Do not reuse a version number that already exists on PyPI.
37
-
38
- ## API Surface Policy
39
-
40
- Expose documented customer-facing routes only. The SDK should map Pythonic
41
- snake_case method parameters to the backend's camelCase JSON body and query
42
- parameters.
43
-
44
- Do not expose hidden, internal, or undocumented routes, including:
45
-
46
- - `/tw-v2/internal/*`
47
- - `/tw-v2/interaction/home-timeline`
48
- - `/tw-v2/interaction/following-timeline`
49
- - `/tw-v2/interaction/search`
50
- - `/tw-v2/interaction/tweet-details`
51
- - device follow notification routes
52
-
53
- Authenticated Twitter/X actions still require a customer-provided `auth_token`
54
- and, when required by the endpoint, `proxy`. The TweetAPI API key is passed by
55
- the client through the `X-API-Key` header.
56
-
57
- ## Code Patterns
58
-
59
- - Resource classes live in `tweetapi/resources/`.
60
- - `TweetAPI` registers resources in `tweetapi/client.py`.
61
- - Lightweight JSON-compatible response and request shapes live in
62
- `tweetapi/types.py` as `TypedDict` definitions and aliases.
63
- - Keep method names and arguments Pythonic (`snake_case`), but serialize request
64
- payload keys exactly as the backend expects (`camelCase`).
65
- - `TweetAPI._get` and `TweetAPI._post` strip `None` values. Prefer passing
66
- optional fields through those helpers instead of hand-filtering in resources.
67
- - Add tests with `responses` for new request paths and serialized bodies.
68
-
69
- ## Verification
70
-
71
- Run these before considering changes ready:
72
-
73
- ```bash
74
- pytest
75
- python -m compileall tweetapi
76
- ```
77
-
78
- For release candidates that add mutating authenticated endpoints, add mocked
79
- request-body tests locally and consider a controlled live smoke test against a
80
- test Twitter/X account before publishing to PyPI.
81
-
82
- ## Customer Safety
83
-
84
- Merging code to GitHub does not automatically update installed customer
85
- packages. Customers receive new SDK code only after a PyPI release and a local
86
- upgrade such as:
87
-
88
- ```bash
89
- python -m pip install --upgrade tweetapi
90
- ```
91
-
92
- Treat PyPI publishing as the production release step for this SDK.
@@ -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,492 +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
- "profile", "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
- @responses.activate
136
- def test_create_post_serializes_reply_option(self):
137
- responses.add(
138
- responses.POST,
139
- f"{BASE_URL}/tw-v2/interaction/create-post",
140
- json={"data": {"id": "1", "action": "create_tweet", "success": True}},
141
- status=200,
142
- )
143
-
144
- client = make_client()
145
- client.post.create_post(
146
- auth_token="auth",
147
- text="Hello",
148
- proxy="h:p@u:p",
149
- reply_option={"mode": "followers", "regions": ["US"]},
150
- )
151
-
152
- body = json.loads(responses.calls[0].request.body)
153
- assert body["replyOption"] == {"mode": "followers", "regions": ["US"]}
154
-
155
- @responses.activate
156
- def test_create_post_with_media_preserves_media_id(self):
157
- responses.add(
158
- responses.POST,
159
- f"{BASE_URL}/tw-v2/interaction/create-post-with-media",
160
- json={"data": {"id": "1", "action": "create_tweet", "success": True}},
161
- status=200,
162
- )
163
-
164
- client = make_client()
165
- client.post.create_post_with_media(
166
- auth_token="auth",
167
- text="Hello",
168
- media=[{"media_id": "1971008286821380096"}],
169
- proxy="h:p@u:p",
170
- )
171
-
172
- body = json.loads(responses.calls[0].request.body)
173
- assert body["media"] == [{"media_id": "1971008286821380096"}]
174
-
175
-
176
- class TestListParity:
177
- @responses.activate
178
- def test_create_sends_camel_case_body(self):
179
- responses.add(
180
- responses.POST,
181
- f"{BASE_URL}/tw-v2/list/create",
182
- json={"data": {"id": "list-1", "name": "Research"}},
183
- status=200,
184
- )
185
-
186
- client = make_client()
187
- client.list.create(
188
- auth_token="auth",
189
- name="Research",
190
- description="Signals",
191
- is_private=False,
192
- )
193
-
194
- body = json.loads(responses.calls[0].request.body)
195
- assert body == {
196
- "authToken": "auth",
197
- "name": "Research",
198
- "description": "Signals",
199
- "isPrivate": False,
200
- }
201
-
202
- @responses.activate
203
- def test_add_member_sends_camel_case_body_and_strips_none(self):
204
- responses.add(
205
- responses.POST,
206
- f"{BASE_URL}/tw-v2/list/add-member",
207
- json={"data": {"success": True}},
208
- status=200,
209
- )
210
-
211
- client = make_client()
212
- client.list.add_member(auth_token="auth", list_id="list-1", user_id="user-1")
213
-
214
- body = json.loads(responses.calls[0].request.body)
215
- assert body == {
216
- "authToken": "auth",
217
- "listId": "list-1",
218
- "userId": "user-1",
219
- }
220
- assert "proxy" not in body
221
-
222
- @responses.activate
223
- def test_remove_member_sends_camel_case_body(self):
224
- responses.add(
225
- responses.POST,
226
- f"{BASE_URL}/tw-v2/list/remove-member",
227
- json={"data": {"success": True}},
228
- status=200,
229
- )
230
-
231
- client = make_client()
232
- client.list.remove_member(
233
- auth_token="auth",
234
- list_id="list-1",
235
- user_id="user-1",
236
- proxy="h:p@u:p",
237
- )
238
-
239
- body = json.loads(responses.calls[0].request.body)
240
- assert body == {
241
- "authToken": "auth",
242
- "listId": "list-1",
243
- "userId": "user-1",
244
- "proxy": "h:p@u:p",
245
- }
246
-
247
-
248
- class TestProfileParity:
249
- @responses.activate
250
- def test_update_sends_body_and_strips_none(self):
251
- responses.add(
252
- responses.POST,
253
- f"{BASE_URL}/tw-v2/profile/update",
254
- json={"data": {"id": "user-1", "name": "New Name"}},
255
- status=200,
256
- )
257
-
258
- client = make_client()
259
- client.profile.update(
260
- auth_token="auth",
261
- name="New Name",
262
- bio=None,
263
- location="Bangkok",
264
- )
265
-
266
- body = json.loads(responses.calls[0].request.body)
267
- assert body == {
268
- "authToken": "auth",
269
- "name": "New Name",
270
- "location": "Bangkok",
271
- }
272
- assert "bio" not in body
273
- assert "website" not in body
274
- assert "proxy" not in body
275
-
276
- @responses.activate
277
- def test_avatar_sends_media_body(self):
278
- responses.add(
279
- responses.POST,
280
- f"{BASE_URL}/tw-v2/profile/avatar",
281
- json={"data": {"id": "user-1", "avatar": "https://example.com/a.jpg"}},
282
- status=200,
283
- )
284
-
285
- client = make_client()
286
- client.profile.avatar(
287
- auth_token="auth",
288
- media={"url": "https://example.com/a.jpg"},
289
- proxy="h:p@u:p",
290
- )
291
-
292
- body = json.loads(responses.calls[0].request.body)
293
- assert body == {
294
- "authToken": "auth",
295
- "media": {"url": "https://example.com/a.jpg"},
296
- "proxy": "h:p@u:p",
297
- }
298
-
299
- @responses.activate
300
- def test_banner_sends_media_body(self):
301
- responses.add(
302
- responses.POST,
303
- f"{BASE_URL}/tw-v2/profile/banner",
304
- json={"data": {"id": "user-1", "banner": "https://example.com/b.jpg"}},
305
- status=200,
306
- )
307
-
308
- client = make_client()
309
- client.profile.banner(
310
- auth_token="auth",
311
- media={"data": "base64", "type": "image/png"},
312
- )
313
-
314
- body = json.loads(responses.calls[0].request.body)
315
- assert body == {
316
- "authToken": "auth",
317
- "media": {"data": "base64", "type": "image/png"},
318
- }
319
-
320
-
321
- class TestCommunityParity:
322
- @responses.activate
323
- def test_create_quote_sends_path_and_body(self):
324
- responses.add(
325
- responses.POST,
326
- f"{BASE_URL}/tw-v2/interaction/create-community-quote",
327
- json={"data": {"id": "tweet-1", "success": True}},
328
- status=200,
329
- )
330
-
331
- client = make_client()
332
- client.community.create_quote(
333
- auth_token="auth",
334
- text="Quote",
335
- attachment_url="https://x.com/user/status/1",
336
- community_id="community-1",
337
- proxy="h:p@u:p",
338
- disable_link_preview=True,
339
- )
340
-
341
- body = json.loads(responses.calls[0].request.body)
342
- assert body == {
343
- "authToken": "auth",
344
- "text": "Quote",
345
- "attachmentUrl": "https://x.com/user/status/1",
346
- "communityId": "community-1",
347
- "proxy": "h:p@u:p",
348
- "disableLinkPreview": True,
349
- }
350
-
351
- @responses.activate
352
- def test_create_quote_with_media_sends_path_and_body(self):
353
- responses.add(
354
- responses.POST,
355
- f"{BASE_URL}/tw-v2/interaction/create-community-quote-with-media",
356
- json={"data": {"id": "tweet-1", "success": True}},
357
- status=200,
358
- )
359
-
360
- client = make_client()
361
- client.community.create_quote_with_media(
362
- auth_token="auth",
363
- text="Quote",
364
- attachment_url="https://x.com/user/status/1",
365
- community_id="community-1",
366
- media=[{"media_id": "1971008286821380096"}],
367
- proxy="h:p@u:p",
368
- )
369
-
370
- body = json.loads(responses.calls[0].request.body)
371
- assert body == {
372
- "authToken": "auth",
373
- "text": "Quote",
374
- "attachmentUrl": "https://x.com/user/status/1",
375
- "communityId": "community-1",
376
- "media": [{"media_id": "1971008286821380096"}],
377
- "proxy": "h:p@u:p",
378
- }
379
-
380
-
381
- class TestErrorHandling:
382
- @responses.activate
383
- def test_validation_error_on_400(self):
384
- responses.add(
385
- responses.GET,
386
- f"{BASE_URL}/tw-v2/user/by-username",
387
- json={"error": {"code": "VALIDATION_ERROR", "message": "Required - username", "details": None}},
388
- status=400,
389
- )
390
-
391
- client = make_client()
392
- with pytest.raises(ValidationError) as exc_info:
393
- client.user.get_by_username(username="")
394
-
395
- assert exc_info.value.code == "VALIDATION_ERROR"
396
- assert exc_info.value.status_code == 400
397
-
398
- @responses.activate
399
- def test_authentication_error_on_401(self):
400
- responses.add(
401
- responses.GET,
402
- f"{BASE_URL}/tw-v2/user/by-username",
403
- json={"error": {"code": "UNAUTHORIZED", "message": "Invalid token", "details": None}},
404
- status=401,
405
- )
406
-
407
- client = make_client()
408
- with pytest.raises(AuthenticationError) as exc_info:
409
- client.user.get_by_username(username="test")
410
-
411
- assert exc_info.value.code == "UNAUTHORIZED"
412
-
413
- @responses.activate
414
- def test_not_found_error_on_404(self):
415
- responses.add(
416
- responses.GET,
417
- f"{BASE_URL}/tw-v2/user/by-username",
418
- json={"error": {"code": "NOT_FOUND", "message": "User not found", "details": None}},
419
- status=404,
420
- )
421
-
422
- client = make_client()
423
- with pytest.raises(NotFoundError) as exc_info:
424
- client.user.get_by_username(username="nonexistent")
425
-
426
- assert exc_info.value.code == "NOT_FOUND"
427
- assert exc_info.value.status_code == 404
428
- assert exc_info.value.message == "User not found"
429
-
430
- @responses.activate
431
- def test_rate_limit_error_with_retry_after(self):
432
- responses.add(
433
- responses.GET,
434
- f"{BASE_URL}/tw-v2/user/by-username",
435
- json={"error": {"code": "RATE_LIMIT", "message": "Rate limit exceeded", "details": {"retryAfter": 30}}},
436
- status=429,
437
- )
438
-
439
- client = make_client()
440
- with pytest.raises(RateLimitError) as exc_info:
441
- client.user.get_by_username(username="test")
442
-
443
- assert exc_info.value.retry_after == 30
444
-
445
- @responses.activate
446
- def test_server_error_on_500(self):
447
- responses.add(
448
- responses.GET,
449
- f"{BASE_URL}/tw-v2/user/by-username",
450
- json={"error": {"code": "INTERNAL_SERVER_ERROR", "message": "Something went wrong", "details": None}},
451
- status=500,
452
- )
453
-
454
- client = make_client()
455
- with pytest.raises(ServerError):
456
- client.user.get_by_username(username="test")
457
-
458
- @responses.activate
459
- def test_malformed_error_response(self):
460
- responses.add(
461
- responses.GET,
462
- f"{BASE_URL}/tw-v2/user/by-username",
463
- body="not json",
464
- status=500,
465
- content_type="text/plain",
466
- )
467
-
468
- client = make_client()
469
- with pytest.raises(ServerError) as exc_info:
470
- client.user.get_by_username(username="test")
471
-
472
- assert exc_info.value.code == "UNKNOWN_ERROR"
473
-
474
- @responses.activate
475
- def test_preserves_api_error_code(self):
476
- responses.add(
477
- responses.GET,
478
- f"{BASE_URL}/tw-v2/user/by-username",
479
- json={"error": {"code": "ACCOUNT_SUSPENDED", "message": "Account suspended", "details": None}},
480
- status=403,
481
- )
482
-
483
- client = make_client()
484
- with pytest.raises(TweetAPIError) as exc_info:
485
- client.user.get_by_username(username="suspended")
486
-
487
- assert exc_info.value.code == "ACCOUNT_SUSPENDED"
488
-
489
- def test_network_error_on_connection_failure(self):
490
- client = TweetAPI(api_key="key", base_url="http://localhost:1", max_retries=0)
491
- with pytest.raises(NetworkError):
492
- 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