tweetapi 1.1.0__tar.gz → 1.2.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/AGENTS.md +92 -0
  2. {tweetapi-1.1.0 → tweetapi-1.2.0}/PKG-INFO +88 -4
  3. {tweetapi-1.1.0 → tweetapi-1.2.0}/README.md +87 -3
  4. {tweetapi-1.1.0 → tweetapi-1.2.0}/pyproject.toml +1 -1
  5. {tweetapi-1.1.0 → tweetapi-1.2.0}/tests/test_client.py +246 -1
  6. {tweetapi-1.1.0 → tweetapi-1.2.0}/tweetapi/__init__.py +1 -1
  7. {tweetapi-1.1.0 → tweetapi-1.2.0}/tweetapi/client.py +2 -0
  8. {tweetapi-1.1.0 → tweetapi-1.2.0}/tweetapi/resources/community.py +19 -2
  9. {tweetapi-1.1.0 → tweetapi-1.2.0}/tweetapi/resources/list_.py +23 -1
  10. {tweetapi-1.1.0 → tweetapi-1.2.0}/tweetapi/resources/post.py +12 -6
  11. tweetapi-1.2.0/tweetapi/resources/profile.py +31 -0
  12. {tweetapi-1.1.0 → tweetapi-1.2.0}/tweetapi/types.py +59 -1
  13. {tweetapi-1.1.0 → tweetapi-1.2.0}/.claude/settings.local.json +0 -0
  14. {tweetapi-1.1.0 → tweetapi-1.2.0}/.gitignore +0 -0
  15. {tweetapi-1.1.0 → tweetapi-1.2.0}/examples/create_post.py +0 -0
  16. {tweetapi-1.1.0 → tweetapi-1.2.0}/examples/get_user.py +0 -0
  17. {tweetapi-1.1.0 → tweetapi-1.2.0}/examples/search_tweets.py +0 -0
  18. {tweetapi-1.1.0 → tweetapi-1.2.0}/tests/__init__.py +0 -0
  19. {tweetapi-1.1.0 → tweetapi-1.2.0}/tests/test_pagination.py +0 -0
  20. {tweetapi-1.1.0 → tweetapi-1.2.0}/tests/test_retry.py +0 -0
  21. {tweetapi-1.1.0 → tweetapi-1.2.0}/tests/test_timeout.py +0 -0
  22. {tweetapi-1.1.0 → tweetapi-1.2.0}/tweetapi/errors.py +0 -0
  23. {tweetapi-1.1.0 → tweetapi-1.2.0}/tweetapi/pagination.py +0 -0
  24. {tweetapi-1.1.0 → tweetapi-1.2.0}/tweetapi/py.typed +0 -0
  25. {tweetapi-1.1.0 → tweetapi-1.2.0}/tweetapi/resources/__init__.py +0 -0
  26. {tweetapi-1.1.0 → tweetapi-1.2.0}/tweetapi/resources/auth.py +0 -0
  27. {tweetapi-1.1.0 → tweetapi-1.2.0}/tweetapi/resources/explore.py +0 -0
  28. {tweetapi-1.1.0 → tweetapi-1.2.0}/tweetapi/resources/interaction.py +0 -0
  29. {tweetapi-1.1.0 → tweetapi-1.2.0}/tweetapi/resources/space.py +0 -0
  30. {tweetapi-1.1.0 → tweetapi-1.2.0}/tweetapi/resources/tweet.py +0 -0
  31. {tweetapi-1.1.0 → tweetapi-1.2.0}/tweetapi/resources/unencrypted_dm.py +0 -0
  32. {tweetapi-1.1.0 → tweetapi-1.2.0}/tweetapi/resources/user.py +0 -0
  33. {tweetapi-1.1.0 → tweetapi-1.2.0}/tweetapi/resources/xchat.py +0 -0
@@ -0,0 +1,92 @@
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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tweetapi
3
- Version: 1.1.0
3
+ Version: 1.2.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 |
@@ -203,6 +216,77 @@ next_page = client.user.get_followers(
203
216
  | `client.dm.get_dm_user_updates(auth_token=..., cursor=...)` | DM user updates |
204
217
  | `client.dm.accept_conversation(auth_token=..., conversation_id=...)` | Accept request |
205
218
 
219
+ ## Posting and Profile Media
220
+
221
+ Tweet media accepts an existing TweetAPI media ID, a URL, or inline base64 data:
222
+
223
+ ```python
224
+ client.post.create_post_with_media(
225
+ auth_token="AUTH_TOKEN",
226
+ text="Launch update",
227
+ media=[{"media_id": "1971008286821380096"}],
228
+ proxy="host:port:user:pass",
229
+ )
230
+ ```
231
+
232
+ Use `reply_option` to set reply controls when creating standard, quote, or media posts:
233
+
234
+ ```python
235
+ client.post.create_post(
236
+ auth_token="AUTH_TOKEN",
237
+ text="Followers can reply",
238
+ proxy="host:port:user:pass",
239
+ reply_option={"mode": "followers"},
240
+ )
241
+ ```
242
+
243
+ Profile avatar and banner uploads accept a URL or inline `data` plus `type`:
244
+
245
+ ```python
246
+ client.profile.update(auth_token="AUTH_TOKEN", name="New Name", bio="Builder")
247
+ client.profile.avatar(
248
+ auth_token="AUTH_TOKEN",
249
+ media={"url": "https://example.com/avatar.jpg"},
250
+ )
251
+ client.profile.banner(
252
+ auth_token="AUTH_TOKEN",
253
+ media={"data": "BASE64_IMAGE_DATA", "type": "image/png"},
254
+ )
255
+ ```
256
+
257
+ Canonical list mutations live under `client.list`; the legacy interaction list helpers remain available:
258
+
259
+ ```python
260
+ created = client.list.create(
261
+ auth_token="AUTH_TOKEN",
262
+ name="Research",
263
+ description="Accounts to watch",
264
+ is_private=True,
265
+ )
266
+ client.list.add_member(auth_token="AUTH_TOKEN", list_id=created["data"]["id"], user_id="123")
267
+ client.list.remove_member(auth_token="AUTH_TOKEN", list_id=created["data"]["id"], user_id="123")
268
+ ```
269
+
270
+ Community quote posts support the same URL and media attachment shapes:
271
+
272
+ ```python
273
+ client.community.create_quote(
274
+ auth_token="AUTH_TOKEN",
275
+ text="Useful context",
276
+ attachment_url="https://x.com/user/status/123",
277
+ community_id="987",
278
+ proxy="host:port:user:pass",
279
+ )
280
+ client.community.create_quote_with_media(
281
+ auth_token="AUTH_TOKEN",
282
+ text="Useful context",
283
+ attachment_url="https://x.com/user/status/123",
284
+ community_id="987",
285
+ media=[{"media_id": "1971008286821380096"}],
286
+ proxy="host:port:user:pass",
287
+ )
288
+ ```
289
+
206
290
  ## Auto-Pagination
207
291
 
208
292
  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 |
@@ -173,6 +186,77 @@ next_page = client.user.get_followers(
173
186
  | `client.dm.get_dm_user_updates(auth_token=..., cursor=...)` | DM user updates |
174
187
  | `client.dm.accept_conversation(auth_token=..., conversation_id=...)` | Accept request |
175
188
 
189
+ ## Posting and Profile Media
190
+
191
+ Tweet media accepts an existing TweetAPI media ID, a URL, or inline base64 data:
192
+
193
+ ```python
194
+ client.post.create_post_with_media(
195
+ auth_token="AUTH_TOKEN",
196
+ text="Launch update",
197
+ media=[{"media_id": "1971008286821380096"}],
198
+ proxy="host:port:user:pass",
199
+ )
200
+ ```
201
+
202
+ Use `reply_option` to set reply controls when creating standard, quote, or media posts:
203
+
204
+ ```python
205
+ client.post.create_post(
206
+ auth_token="AUTH_TOKEN",
207
+ text="Followers can reply",
208
+ proxy="host:port:user:pass",
209
+ reply_option={"mode": "followers"},
210
+ )
211
+ ```
212
+
213
+ Profile avatar and banner uploads accept a URL or inline `data` plus `type`:
214
+
215
+ ```python
216
+ client.profile.update(auth_token="AUTH_TOKEN", name="New Name", bio="Builder")
217
+ client.profile.avatar(
218
+ auth_token="AUTH_TOKEN",
219
+ media={"url": "https://example.com/avatar.jpg"},
220
+ )
221
+ client.profile.banner(
222
+ auth_token="AUTH_TOKEN",
223
+ media={"data": "BASE64_IMAGE_DATA", "type": "image/png"},
224
+ )
225
+ ```
226
+
227
+ Canonical list mutations live under `client.list`; the legacy interaction list helpers remain available:
228
+
229
+ ```python
230
+ created = client.list.create(
231
+ auth_token="AUTH_TOKEN",
232
+ name="Research",
233
+ description="Accounts to watch",
234
+ is_private=True,
235
+ )
236
+ client.list.add_member(auth_token="AUTH_TOKEN", list_id=created["data"]["id"], user_id="123")
237
+ client.list.remove_member(auth_token="AUTH_TOKEN", list_id=created["data"]["id"], user_id="123")
238
+ ```
239
+
240
+ Community quote posts support the same URL and media attachment shapes:
241
+
242
+ ```python
243
+ client.community.create_quote(
244
+ auth_token="AUTH_TOKEN",
245
+ text="Useful context",
246
+ attachment_url="https://x.com/user/status/123",
247
+ community_id="987",
248
+ proxy="host:port:user:pass",
249
+ )
250
+ client.community.create_quote_with_media(
251
+ auth_token="AUTH_TOKEN",
252
+ text="Useful context",
253
+ attachment_url="https://x.com/user/status/123",
254
+ community_id="987",
255
+ media=[{"media_id": "1971008286821380096"}],
256
+ proxy="host:port:user:pass",
257
+ )
258
+ ```
259
+
176
260
  ## Auto-Pagination
177
261
 
178
262
  Use the `paginate()` and `paginate_pages()` helpers to iterate through all pages automatically:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "tweetapi"
7
- version = "1.1.0"
7
+ version = "1.2.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"
@@ -35,7 +35,7 @@ class TestClientInit:
35
35
  def test_all_resources_exist(self):
36
36
  client = make_client()
37
37
  for attr in ["user", "tweet", "post", "interaction", "list", "community",
38
- "space", "explore", "auth", "xchat", "dm"]:
38
+ "profile", "space", "explore", "auth", "xchat", "dm"]:
39
39
  assert hasattr(client, attr), f"Missing resource: {attr}"
40
40
 
41
41
 
@@ -132,6 +132,251 @@ class TestPostRequests:
132
132
  body = json.loads(responses.calls[0].request.body)
133
133
  assert "disableLinkPreview" not in body
134
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
+
135
380
 
136
381
  class TestErrorHandling:
137
382
  @responses.activate
@@ -37,4 +37,4 @@ __all__ = [
37
37
  "paginate_pages",
38
38
  ]
39
39
 
40
- __version__ = "1.1.0"
40
+ __version__ = "1.2.0"
@@ -78,6 +78,7 @@ class TweetAPI:
78
78
  from .resources.post import PostResource
79
79
  from .resources.interaction import InteractionResource
80
80
  from .resources.list_ import ListResource
81
+ from .resources.profile import ProfileResource
81
82
  from .resources.community import CommunityResource
82
83
  from .resources.space import SpaceResource
83
84
  from .resources.explore import ExploreResource
@@ -90,6 +91,7 @@ class TweetAPI:
90
91
  self.post = PostResource(self)
91
92
  self.interaction = InteractionResource(self)
92
93
  self.list = ListResource(self)
94
+ self.profile = ProfileResource(self)
93
95
  self.community = CommunityResource(self)
94
96
  self.space = SpaceResource(self)
95
97
  self.explore = ExploreResource(self)
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Optional, TYPE_CHECKING
3
+ from typing import Optional, TYPE_CHECKING
4
4
 
5
5
  if TYPE_CHECKING:
6
6
  from ..client import TweetAPI
@@ -9,6 +9,7 @@ if TYPE_CHECKING:
9
9
  CommunityResponse,
10
10
  CommunityMemberPaginatedResponse,
11
11
  CommunitySearchPaginatedResponse,
12
+ TweetMediaInput,
12
13
  TweetsPaginatedResponse,
13
14
  )
14
15
 
@@ -40,13 +41,29 @@ class CommunityResource:
40
41
  "proxy": proxy, "disableLinkPreview": disable_link_preview,
41
42
  })
42
43
 
43
- def create_post_with_media(self, *, auth_token: str, text: str, community_id: str, media: list[Any], proxy: str, disable_link_preview: Optional[bool] = None) -> ActionResponse:
44
+ def create_post_with_media(self, *, auth_token: str, text: str, community_id: str, media: list[TweetMediaInput], proxy: str, disable_link_preview: Optional[bool] = None) -> ActionResponse:
44
45
  """Create a post with media in a community."""
45
46
  return self._client._post("/tw-v2/interaction/create-community-post-with-media", {
46
47
  "authToken": auth_token, "text": text, "communityId": community_id,
47
48
  "media": media, "proxy": proxy, "disableLinkPreview": disable_link_preview,
48
49
  })
49
50
 
51
+ def create_quote(self, *, auth_token: str, text: str, attachment_url: str, community_id: str, proxy: str, disable_link_preview: Optional[bool] = None) -> ActionResponse:
52
+ """Create a quote post in a community."""
53
+ return self._client._post("/tw-v2/interaction/create-community-quote", {
54
+ "authToken": auth_token, "text": text, "attachmentUrl": attachment_url,
55
+ "communityId": community_id, "proxy": proxy,
56
+ "disableLinkPreview": disable_link_preview,
57
+ })
58
+
59
+ def create_quote_with_media(self, *, auth_token: str, text: str, attachment_url: str, community_id: str, media: list[TweetMediaInput], proxy: str, disable_link_preview: Optional[bool] = None) -> ActionResponse:
60
+ """Create a quote post with media in a community."""
61
+ return self._client._post("/tw-v2/interaction/create-community-quote-with-media", {
62
+ "authToken": auth_token, "text": text, "attachmentUrl": attachment_url,
63
+ "communityId": community_id, "media": media, "proxy": proxy,
64
+ "disableLinkPreview": disable_link_preview,
65
+ })
66
+
50
67
  def reply_post(self, *, auth_token: str, text: str, tweet_id: str, community_id: str, proxy: str, disable_link_preview: Optional[bool] = None) -> ActionResponse:
51
68
  """Reply to a community post."""
52
69
  return self._client._post("/tw-v2/interaction/reply-community-post", {
@@ -1,10 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Optional, TYPE_CHECKING
3
+ from typing import Optional, TYPE_CHECKING
4
4
 
5
5
  if TYPE_CHECKING:
6
6
  from ..client import TweetAPI
7
7
  from ..types import (
8
+ ActionResponse,
8
9
  ListResponse,
9
10
  TweetsPaginatedResponse,
10
11
  UserPaginatedResponse,
@@ -30,3 +31,24 @@ class ListResource:
30
31
  def get_followers(self, *, list_id: str, cursor: Optional[str] = None) -> UserPaginatedResponse:
31
32
  """Get followers of a list."""
32
33
  return self._client._get("/tw-v2/list/followers", {"listId": list_id, "cursor": cursor})
34
+
35
+ def create(self, *, auth_token: str, name: str, description: str = "", is_private: bool = True) -> ListResponse:
36
+ """Create a list."""
37
+ return self._client._post("/tw-v2/list/create", {
38
+ "authToken": auth_token, "name": name, "description": description,
39
+ "isPrivate": is_private,
40
+ })
41
+
42
+ def add_member(self, *, auth_token: str, list_id: str, user_id: str, proxy: Optional[str] = None) -> ActionResponse:
43
+ """Add a user to a list."""
44
+ return self._client._post("/tw-v2/list/add-member", {
45
+ "authToken": auth_token, "listId": list_id, "userId": user_id,
46
+ "proxy": proxy,
47
+ })
48
+
49
+ def remove_member(self, *, auth_token: str, list_id: str, user_id: str, proxy: Optional[str] = None) -> ActionResponse:
50
+ """Remove a user from a list."""
51
+ return self._client._post("/tw-v2/list/remove-member", {
52
+ "authToken": auth_token, "listId": list_id, "userId": user_id,
53
+ "proxy": proxy,
54
+ })
@@ -4,32 +4,38 @@ from typing import Any, Optional, TYPE_CHECKING
4
4
 
5
5
  if TYPE_CHECKING:
6
6
  from ..client import TweetAPI
7
- from ..types import ActionResponse
7
+ from ..types import ActionResponse, ReplyOption, TweetMediaInput
8
8
 
9
9
 
10
10
  class PostResource:
11
11
  def __init__(self, client: TweetAPI) -> None:
12
12
  self._client = client
13
13
 
14
- def create_post(self, *, auth_token: str, text: str, proxy: str, disable_link_preview: Optional[bool] = None) -> ActionResponse:
14
+ def create_post(self, *, auth_token: str, text: str, proxy: str, disable_link_preview: Optional[bool] = None, reply_option: Optional[ReplyOption] = None) -> ActionResponse:
15
15
  """Create a new tweet."""
16
16
  return self._client._post("/tw-v2/interaction/create-post", {
17
17
  "authToken": auth_token, "text": text, "proxy": proxy,
18
- "disableLinkPreview": disable_link_preview,
18
+ "disableLinkPreview": disable_link_preview, "replyOption": reply_option,
19
19
  })
20
20
 
21
- def create_post_quote(self, *, auth_token: str, text: str, attachment_url: str, proxy: str, disable_link_preview: Optional[bool] = None) -> ActionResponse:
21
+ def create_post_quote(self, *, auth_token: str, text: str, attachment_url: str, proxy: str, disable_link_preview: Optional[bool] = None, reply_option: Optional[ReplyOption] = None) -> ActionResponse:
22
22
  """Create a quote tweet."""
23
23
  return self._client._post("/tw-v2/interaction/create-post-quote", {
24
24
  "authToken": auth_token, "text": text, "attachmentUrl": attachment_url,
25
25
  "proxy": proxy, "disableLinkPreview": disable_link_preview,
26
+ "replyOption": reply_option,
26
27
  })
27
28
 
28
- def create_post_with_media(self, *, auth_token: str, text: str, media: list[Any], proxy: str, disable_link_preview: Optional[bool] = None) -> ActionResponse:
29
- """Create a tweet with media attachments."""
29
+ def create_post_with_media(self, *, auth_token: str, text: str, media: list[TweetMediaInput], proxy: str, disable_link_preview: Optional[bool] = None, reply_option: Optional[ReplyOption] = None) -> ActionResponse:
30
+ """Create a tweet with media attachments.
31
+
32
+ ``media`` accepts items shaped as ``{"media_id": str}``,
33
+ ``{"url": str, "type": str | None}``, or ``{"data": str, "type": str}``.
34
+ """
30
35
  return self._client._post("/tw-v2/interaction/create-post-with-media", {
31
36
  "authToken": auth_token, "text": text, "media": media,
32
37
  "proxy": proxy, "disableLinkPreview": disable_link_preview,
38
+ "replyOption": reply_option,
33
39
  })
34
40
 
35
41
  def reply_post(self, *, auth_token: str, text: str, tweet_id: str, proxy: str, disable_link_preview: Optional[bool] = None) -> ActionResponse:
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional, TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ from ..client import TweetAPI
7
+ from ..types import ProfileMediaInput, UserResponse
8
+
9
+
10
+ class ProfileResource:
11
+ def __init__(self, client: TweetAPI) -> None:
12
+ self._client = client
13
+
14
+ def update(self, *, auth_token: str, name: Optional[str] = None, bio: Optional[str] = None, location: Optional[str] = None, website: Optional[str] = None, proxy: Optional[str] = None) -> UserResponse:
15
+ """Update the authenticated user's profile."""
16
+ return self._client._post("/tw-v2/profile/update", {
17
+ "authToken": auth_token, "name": name, "bio": bio,
18
+ "location": location, "website": website, "proxy": proxy,
19
+ })
20
+
21
+ def avatar(self, *, auth_token: str, media: ProfileMediaInput, proxy: Optional[str] = None) -> UserResponse:
22
+ """Update the authenticated user's avatar."""
23
+ return self._client._post("/tw-v2/profile/avatar", {
24
+ "authToken": auth_token, "media": media, "proxy": proxy,
25
+ })
26
+
27
+ def banner(self, *, auth_token: str, media: ProfileMediaInput, proxy: Optional[str] = None) -> UserResponse:
28
+ """Update the authenticated user's banner."""
29
+ return self._client._post("/tw-v2/profile/banner", {
30
+ "authToken": auth_token, "media": media, "proxy": proxy,
31
+ })
@@ -6,7 +6,7 @@ All types mirror the API's JSON response shapes exactly.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- from typing import Any, List as TypingList, Optional, TypedDict
9
+ from typing import Any, List as TypingList, Optional, TypedDict, Union
10
10
 
11
11
 
12
12
  # ─── Response Wrappers ───────────────────────────────────────────────────────
@@ -146,6 +146,42 @@ class UserAnalytics(TypedDict, total=False):
146
146
  # ─── Media ───────────────────────────────────────────────────────────────────
147
147
 
148
148
 
149
+ class TweetMediaIdInput(TypedDict):
150
+ media_id: str
151
+
152
+
153
+ class TweetMediaUrlInputRequired(TypedDict):
154
+ url: str
155
+
156
+
157
+ class TweetMediaUrlInput(TweetMediaUrlInputRequired, total=False):
158
+ type: str
159
+
160
+
161
+ class TweetMediaDataInput(TypedDict):
162
+ data: str
163
+ type: str
164
+
165
+
166
+ TweetMediaInput = Union[TweetMediaIdInput, TweetMediaUrlInput, TweetMediaDataInput]
167
+
168
+
169
+ class ProfileMediaUrlInputRequired(TypedDict):
170
+ url: str
171
+
172
+
173
+ class ProfileMediaUrlInput(ProfileMediaUrlInputRequired, total=False):
174
+ type: str
175
+
176
+
177
+ class ProfileMediaDataInput(TypedDict):
178
+ data: str
179
+ type: str
180
+
181
+
182
+ ProfileMediaInput = Union[ProfileMediaUrlInput, ProfileMediaDataInput]
183
+
184
+
149
185
  class MediaSize(TypedDict, total=False):
150
186
  width: int
151
187
  height: int
@@ -280,6 +316,27 @@ class ConversationControl(TypedDict, total=False):
280
316
  allowedUserIds: list[str]
281
317
 
282
318
 
319
+ class ReplyOptionRequired(TypedDict):
320
+ mode: str
321
+
322
+
323
+ class ReplyOption(ReplyOptionRequired, total=False):
324
+ regions: list[str]
325
+
326
+
327
+ class ReactionTargetAuthor(TypedDict, total=False):
328
+ id: str
329
+ username: str
330
+ name: str
331
+
332
+
333
+ class ReactionContext(TypedDict, total=False):
334
+ type: str
335
+ targetTweetId: str
336
+ targetTweetUrl: str
337
+ targetAuthor: ReactionTargetAuthor
338
+
339
+
283
340
  class Tweet(TypedDict, total=False):
284
341
  id: str
285
342
  conversationId: Optional[str]
@@ -316,6 +373,7 @@ class Tweet(TypedDict, total=False):
316
373
  hasBirdwatchNotes: bool
317
374
  birdwatchPivot: Optional[BirdwatchPivot]
318
375
  conversationControl: Optional[ConversationControl]
376
+ reactionContext: Optional[ReactionContext]
319
377
  isPromoted: bool
320
378
  communityId: Optional[str]
321
379
  createdAt: Optional[str]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes