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.
- {tweetapi-1.2.0 → tweetapi-2.0.0}/.gitignore +2 -0
- {tweetapi-1.2.0 → tweetapi-2.0.0}/PKG-INFO +4 -2
- {tweetapi-1.2.0 → tweetapi-2.0.0}/README.md +3 -1
- {tweetapi-1.2.0 → tweetapi-2.0.0}/pyproject.toml +8 -1
- {tweetapi-1.2.0 → tweetapi-2.0.0}/tweetapi/__init__.py +1 -1
- {tweetapi-1.2.0 → tweetapi-2.0.0}/tweetapi/resources/auth.py +4 -3
- tweetapi-1.2.0/.claude/settings.local.json +0 -3
- tweetapi-1.2.0/AGENTS.md +0 -92
- tweetapi-1.2.0/examples/create_post.py +0 -37
- tweetapi-1.2.0/examples/get_user.py +0 -31
- tweetapi-1.2.0/examples/search_tweets.py +0 -20
- tweetapi-1.2.0/tests/test_client.py +0 -492
- tweetapi-1.2.0/tests/test_pagination.py +0 -101
- tweetapi-1.2.0/tests/test_retry.py +0 -148
- tweetapi-1.2.0/tests/test_timeout.py +0 -56
- tweetapi-1.2.0/tweetapi/resources/__init__.py +0 -0
- {tweetapi-1.2.0 → tweetapi-2.0.0}/tweetapi/client.py +0 -0
- {tweetapi-1.2.0 → tweetapi-2.0.0}/tweetapi/errors.py +0 -0
- {tweetapi-1.2.0 → tweetapi-2.0.0}/tweetapi/pagination.py +0 -0
- {tweetapi-1.2.0 → tweetapi-2.0.0}/tweetapi/py.typed +0 -0
- {tweetapi-1.2.0/tests → tweetapi-2.0.0/tweetapi/resources}/__init__.py +0 -0
- {tweetapi-1.2.0 → tweetapi-2.0.0}/tweetapi/resources/community.py +0 -0
- {tweetapi-1.2.0 → tweetapi-2.0.0}/tweetapi/resources/explore.py +0 -0
- {tweetapi-1.2.0 → tweetapi-2.0.0}/tweetapi/resources/interaction.py +0 -0
- {tweetapi-1.2.0 → tweetapi-2.0.0}/tweetapi/resources/list_.py +0 -0
- {tweetapi-1.2.0 → tweetapi-2.0.0}/tweetapi/resources/post.py +0 -0
- {tweetapi-1.2.0 → tweetapi-2.0.0}/tweetapi/resources/profile.py +0 -0
- {tweetapi-1.2.0 → tweetapi-2.0.0}/tweetapi/resources/space.py +0 -0
- {tweetapi-1.2.0 → tweetapi-2.0.0}/tweetapi/resources/tweet.py +0 -0
- {tweetapi-1.2.0 → tweetapi-2.0.0}/tweetapi/resources/unencrypted_dm.py +0 -0
- {tweetapi-1.2.0 → tweetapi-2.0.0}/tweetapi/resources/user.py +0 -0
- {tweetapi-1.2.0 → tweetapi-2.0.0}/tweetapi/resources/xchat.py +0 -0
- {tweetapi-1.2.0 → tweetapi-2.0.0}/tweetapi/types.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
|
|
@@ -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 = "
|
|
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
|
|
|
@@ -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
|
})
|
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
|
|
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
|
|
File without changes
|