langchain-xdataapi 0.1.0__tar.gz → 0.1.3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: langchain-xdataapi
3
- Version: 0.1.0
3
+ Version: 0.1.3
4
4
  Summary: LangChain tools for xdataapi.io: read-only public X (Twitter) profiles, tweets, threads, followers and search.
5
5
  Project-URL: Homepage, https://xdataapi.io
6
6
  Project-URL: Documentation, https://xdataapi.io/docs
@@ -68,8 +68,8 @@ print(search.invoke({"q": "from:vercel since:2026-09-01", "count": 10}))
68
68
  | Tool | Reads | Credits |
69
69
  |---|---|---|
70
70
  | `x_search_tweets` | Search with the x.com operators | 1 per result |
71
- | `x_get_user` | One profile | 1 |
72
- | `x_get_users` | Up to 100 profiles in one call | 1 per profile |
71
+ | `x_get_user` | One profile, by `handle` or by numeric user `id` | 1 |
72
+ | `x_get_users` | Up to 100 profiles in one call, by `handles` or by `ids` | 1 per profile |
73
73
  | `x_get_user_tweets` | Latest posts of a user | 1 per post |
74
74
  | `x_get_tweet` | One post | 1 |
75
75
  | `x_get_tweets` | Up to 100 posts in one call | 1 per post |
@@ -46,8 +46,8 @@ print(search.invoke({"q": "from:vercel since:2026-09-01", "count": 10}))
46
46
  | Tool | Reads | Credits |
47
47
  |---|---|---|
48
48
  | `x_search_tweets` | Search with the x.com operators | 1 per result |
49
- | `x_get_user` | One profile | 1 |
50
- | `x_get_users` | Up to 100 profiles in one call | 1 per profile |
49
+ | `x_get_user` | One profile, by `handle` or by numeric user `id` | 1 |
50
+ | `x_get_users` | Up to 100 profiles in one call, by `handles` or by `ids` | 1 per profile |
51
51
  | `x_get_user_tweets` | Latest posts of a user | 1 per post |
52
52
  | `x_get_tweet` | One post | 1 |
53
53
  | `x_get_tweets` | Up to 100 posts in one call | 1 per post |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "langchain-xdataapi"
3
- version = "0.1.0"
3
+ version = "0.1.3"
4
4
  description = "LangChain tools for xdataapi.io: read-only public X (Twitter) profiles, tweets, threads, followers and search."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -7,19 +7,34 @@ model that plans a multi-step read knows what the plan costs before it runs it.
7
7
  from __future__ import annotations
8
8
 
9
9
  import json
10
+ import re
10
11
  from typing import Any, Literal, Sequence
12
+ from urllib.parse import quote
11
13
 
12
14
  from langchain_core.callbacks import AsyncCallbackManagerForToolRun, CallbackManagerForToolRun
13
15
  from langchain_core.tools import BaseTool
14
- from pydantic import BaseModel, ConfigDict, Field
16
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
15
17
 
16
18
  from .client import XdataapiClient
17
19
 
18
20
 
21
+ _USER_ID = re.compile(r"^\d{1,19}$")
22
+
23
+
19
24
  def _handle(value: str) -> str:
20
25
  return value.strip().lstrip("@")
21
26
 
22
27
 
28
+ def _seg(value: Any) -> str:
29
+ """One URL path segment. The value comes from a model, so nothing in it may
30
+ act as a path: a slash is sent percent-encoded, and a segment that is empty
31
+ or only dots ("..") is refused, because encoding does not change a dot."""
32
+ s = str(value).strip()
33
+ if s.strip(".") == "":
34
+ raise ValueError("a handle, user or id must not be empty or only dots")
35
+ return quote(s, safe="")
36
+
37
+
23
38
  def _dump(payload: dict[str, Any]) -> str:
24
39
  return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
25
40
 
@@ -47,39 +62,80 @@ class _Base(BaseTool):
47
62
  # --- users ---------------------------------------------------------------
48
63
 
49
64
 
65
+ def _one_of(first: Any, second: Any, names: str) -> None:
66
+ """A user is named by handle or by id, never by both and never by neither."""
67
+ if (first is None) == (second is None):
68
+ raise ValueError(f"give {names}, one of the two")
69
+
70
+
50
71
  class _UserArgs(BaseModel):
51
- handle: str = Field(description="X handle, with or without the leading @.")
72
+ handle: str | None = Field(default=None, description="X handle, with or without the leading @.")
73
+ id: str | None = Field(
74
+ default=None,
75
+ pattern=r"^\d{1,19}$",
76
+ description="Numeric user id. It stays the same when the user changes the handle.",
77
+ )
52
78
  fresh: bool | None = Field(default=None, description="Skip the cache. Costs double.")
53
79
 
80
+ @model_validator(mode="after")
81
+ def _handle_or_id(self) -> "_UserArgs":
82
+ _one_of(self.handle, self.id, "handle or id")
83
+ return self
84
+
54
85
 
55
86
  class GetUser(_Base):
56
87
  name: str = "x_get_user"
57
88
  description: str = (
58
- "Public profile of one X (Twitter) user by handle: bio, counts, verified state, "
59
- "created date. Costs 1 credit."
89
+ "Public profile of one X (Twitter) user, by handle or by numeric user id (give one of "
90
+ "the two): bio, counts, verified state, created date. The id stays the same when the "
91
+ "user changes the handle. Costs 1 credit."
60
92
  )
61
93
  args_schema: type[BaseModel] = _UserArgs
62
94
 
63
- def _call(self, handle: str, fresh: bool | None = None) -> tuple[str, dict[str, Any] | None]:
64
- return f"/v1/users/{_handle(handle)}", {"fresh": fresh}
95
+ def _call(
96
+ self, handle: str | None = None, id: str | None = None, fresh: bool | None = None
97
+ ) -> tuple[str, dict[str, Any] | None]:
98
+ _one_of(handle, id, "handle or id")
99
+ if id is not None:
100
+ if not _USER_ID.match(id.strip()):
101
+ raise ValueError("id must be a numeric user id")
102
+ return f"/v1/users/id/{_seg(id)}", {"fresh": fresh}
103
+ return f"/v1/users/{_seg(_handle(handle or ''))}", {"fresh": fresh}
65
104
 
66
105
 
67
106
  class _UsersArgs(BaseModel):
68
- handles: Sequence[str] = Field(description="Up to 100 handles in one call.")
107
+ handles: Sequence[str] | None = Field(default=None, description="Up to 100 handles in one call.")
108
+ ids: Sequence[str] | None = Field(
109
+ default=None, description="Up to 100 numeric user ids in one call, in place of handles."
110
+ )
69
111
  fresh: bool | None = Field(default=None, description="Skip the cache. Costs double.")
70
112
 
113
+ @model_validator(mode="after")
114
+ def _handles_or_ids(self) -> "_UsersArgs":
115
+ _one_of(self.handles, self.ids, "handles or ids")
116
+ return self
117
+
71
118
 
72
119
  class GetUsers(_Base):
73
120
  name: str = "x_get_users"
74
121
  description: str = (
75
- "Profiles of up to 100 X users in ONE call. Prefer this over repeated single reads: "
122
+ "Profiles of up to 100 X users in ONE call, by handle (handles) or by numeric user id "
123
+ "(ids); give one of the two lists. Prefer this over repeated single reads: "
76
124
  "it is one request and one rate-limit slot. Costs 1 credit per profile found; "
77
- "handles that do not exist are listed in errors and cost nothing."
125
+ "inputs that do not exist are listed in errors and cost nothing."
78
126
  )
79
127
  args_schema: type[BaseModel] = _UsersArgs
80
128
 
81
- def _call(self, handles: Sequence[str], fresh: bool | None = None) -> tuple[str, dict[str, Any] | None]:
82
- return "/v1/users/batch", {"handles": ",".join(_handle(h) for h in handles), "fresh": fresh}
129
+ def _call(
130
+ self,
131
+ handles: Sequence[str] | None = None,
132
+ ids: Sequence[str] | None = None,
133
+ fresh: bool | None = None,
134
+ ) -> tuple[str, dict[str, Any] | None]:
135
+ _one_of(handles, ids, "handles or ids")
136
+ if ids is not None:
137
+ return "/v1/users/batch", {"ids": ",".join(i.strip() for i in ids), "fresh": fresh}
138
+ return "/v1/users/batch", {"handles": ",".join(_handle(h) for h in handles or []), "fresh": fresh}
83
139
 
84
140
 
85
141
  class _PageArgs(BaseModel):
@@ -99,7 +155,7 @@ class GetUserTweets(_Base):
99
155
  def _call(
100
156
  self, user: str, count: int | None = None, cursor: str | None = None, fresh: bool | None = None
101
157
  ) -> tuple[str, dict[str, Any] | None]:
102
- return f"/v1/users/{_handle(user)}/tweets", {
158
+ return f"/v1/users/{_seg(_handle(user))}/tweets", {
103
159
  "count": self.client.cap(count),
104
160
  "cursor": cursor,
105
161
  "fresh": fresh,
@@ -117,7 +173,7 @@ class GetFollowers(_Base):
117
173
  def _call(
118
174
  self, user: str, count: int | None = None, cursor: str | None = None, fresh: bool | None = None
119
175
  ) -> tuple[str, dict[str, Any] | None]:
120
- return f"/v1/users/{_handle(user)}/followers", {
176
+ return f"/v1/users/{_seg(_handle(user))}/followers", {
121
177
  "count": self.client.cap(count),
122
178
  "cursor": cursor,
123
179
  "fresh": fresh,
@@ -135,7 +191,7 @@ class GetFollowerIds(_Base):
135
191
  def _call(
136
192
  self, user: str, count: int | None = None, cursor: str | None = None, fresh: bool | None = None
137
193
  ) -> tuple[str, dict[str, Any] | None]:
138
- return f"/v1/users/{_handle(user)}/followers/ids", {
194
+ return f"/v1/users/{_seg(_handle(user))}/followers/ids", {
139
195
  "count": self.client.cap(count),
140
196
  "cursor": cursor,
141
197
  "fresh": fresh,
@@ -153,7 +209,7 @@ class GetFollowing(_Base):
153
209
  def _call(
154
210
  self, user: str, count: int | None = None, cursor: str | None = None, fresh: bool | None = None
155
211
  ) -> tuple[str, dict[str, Any] | None]:
156
- return f"/v1/users/{_handle(user)}/following", {
212
+ return f"/v1/users/{_seg(_handle(user))}/following", {
157
213
  "count": self.client.cap(count),
158
214
  "cursor": cursor,
159
215
  "fresh": fresh,
@@ -220,7 +276,7 @@ class GetTweet(_Base):
220
276
  args_schema: type[BaseModel] = _TweetArgs
221
277
 
222
278
  def _call(self, id: str, fresh: bool | None = None) -> tuple[str, dict[str, Any] | None]:
223
- return f"/v1/tweets/{id}", {"fresh": fresh}
279
+ return f"/v1/tweets/{_seg(id)}", {"fresh": fresh}
224
280
 
225
281
 
226
282
  class _TweetsArgs(BaseModel):
@@ -257,7 +313,7 @@ class GetThread(_Base):
257
313
  def _call(
258
314
  self, id: str, cursor: str | None = None, fresh: bool | None = None
259
315
  ) -> tuple[str, dict[str, Any] | None]:
260
- return f"/v1/tweets/{id}/thread", {"cursor": cursor, "fresh": fresh}
316
+ return f"/v1/tweets/{_seg(id)}/thread", {"cursor": cursor, "fresh": fresh}
261
317
 
262
318
 
263
319
  class GetReplies(_Base):
@@ -268,7 +324,7 @@ class GetReplies(_Base):
268
324
  def _call(
269
325
  self, id: str, cursor: str | None = None, fresh: bool | None = None
270
326
  ) -> tuple[str, dict[str, Any] | None]:
271
- return f"/v1/tweets/{id}/replies", {"cursor": cursor, "fresh": fresh}
327
+ return f"/v1/tweets/{_seg(id)}/replies", {"cursor": cursor, "fresh": fresh}
272
328
 
273
329
 
274
330
  class _CountedTweetArgs(BaseModel):
@@ -286,7 +342,7 @@ class GetQuotes(_Base):
286
342
  def _call(
287
343
  self, id: str, count: int | None = None, cursor: str | None = None, fresh: bool | None = None
288
344
  ) -> tuple[str, dict[str, Any] | None]:
289
- return f"/v1/tweets/{id}/quotes", {
345
+ return f"/v1/tweets/{_seg(id)}/quotes", {
290
346
  "count": self.client.cap(count),
291
347
  "cursor": cursor,
292
348
  "fresh": fresh,
@@ -304,7 +360,7 @@ class GetRetweeters(_Base):
304
360
  def _call(
305
361
  self, id: str, count: int | None = None, cursor: str | None = None, fresh: bool | None = None
306
362
  ) -> tuple[str, dict[str, Any] | None]:
307
- return f"/v1/tweets/{id}/retweeters", {
363
+ return f"/v1/tweets/{_seg(id)}/retweeters", {
308
364
  "count": self.client.cap(count),
309
365
  "cursor": cursor,
310
366
  "fresh": fresh,
@@ -8,6 +8,7 @@ from langchain_xdataapi import (
8
8
  GetBalance,
9
9
  GetTweets,
10
10
  GetUser,
11
+ GetUsers,
11
12
  SearchTweets,
12
13
  XdataapiAuthError,
13
14
  XdataapiClient,
@@ -32,6 +33,65 @@ def test_get_user_strips_the_at_sign():
32
33
  assert json.loads(out)["credits_charged"] == 1
33
34
 
34
35
 
36
+ @respx.mock
37
+ def test_get_user_by_id_reads_the_id_route():
38
+ route = respx.get(f"{BASE}/v1/users/id/783214").mock(
39
+ return_value=httpx.Response(200, json={"data": {"id": "783214", "handle": "X"}, "credits_charged": 1})
40
+ )
41
+ out = GetUser(client=client()).invoke({"id": "783214"})
42
+ assert route.called
43
+ assert json.loads(out)["data"]["handle"] == "X"
44
+
45
+
46
+ def test_get_user_wants_a_handle_or_an_id_and_not_both():
47
+ tool = GetUser(client=client())
48
+ with pytest.raises(Exception, match="handle or id"):
49
+ tool.invoke({})
50
+ with pytest.raises(Exception, match="handle or id"):
51
+ tool.invoke({"handle": "x", "id": "783214"})
52
+ with pytest.raises(Exception):
53
+ tool.invoke({"id": "not-a-number"})
54
+
55
+
56
+ @respx.mock
57
+ def test_a_model_argument_cannot_leave_its_path_segment():
58
+ from langchain_xdataapi import GetTweet, GetUserTweets
59
+
60
+ route = respx.route(method="GET", host="api.xdataapi.io").mock(return_value=httpx.Response(404, json={"error": {"code": "not_found"}}))
61
+ GetUser(client=client()).invoke({"handle": "../me"})
62
+ assert route.calls.last.request.url.raw_path.startswith(b"/v1/users/..%2Fme")
63
+ GetUserTweets(client=client()).invoke({"user": "x/../../me"})
64
+ assert route.calls.last.request.url.raw_path.startswith(b"/v1/users/x%2F..%2F..%2Fme/tweets")
65
+ GetTweet(client=client()).invoke({"id": "20/../../me"})
66
+ assert route.calls.last.request.url.raw_path.startswith(b"/v1/tweets/20%2F..%2F..%2Fme")
67
+ # Encoding does not change a dot, so a segment of only dots is refused.
68
+ before = route.call_count
69
+ for bad in ("..", ".", " "):
70
+ with pytest.raises(ValueError, match="only dots"):
71
+ GetUserTweets(client=client()).invoke({"user": bad})
72
+ with pytest.raises(ValueError, match="only dots"):
73
+ GetTweet(client=client()).invoke({"id": bad})
74
+ assert route.call_count == before
75
+ # The id route takes digits only, also when the tool is called without its schema.
76
+ with pytest.raises(ValueError, match="numeric user id"):
77
+ GetUser(client=client())._call(id="1/../../me")
78
+
79
+
80
+ @respx.mock
81
+ def test_users_batch_by_ids_sends_ids_and_no_handles():
82
+ route = respx.get(f"{BASE}/v1/users/batch").mock(return_value=httpx.Response(200, json={"data": []}))
83
+ GetUsers(client=client()).invoke({"ids": ["783214", "13334762"]})
84
+ params = route.calls.last.request.url.params
85
+ assert params["ids"] == "783214,13334762"
86
+ assert "handles" not in params
87
+ GetUsers(client=client()).invoke({"handles": ["@x", "github"]})
88
+ params = route.calls.last.request.url.params
89
+ assert params["handles"] == "x,github"
90
+ assert "ids" not in params
91
+ with pytest.raises(Exception, match="handles or ids"):
92
+ GetUsers(client=client()).invoke({"handles": ["x"], "ids": ["1"]})
93
+
94
+
35
95
  @respx.mock
36
96
  def test_search_passes_the_operators_through_untouched():
37
97
  route = respx.get(f"{BASE}/v1/tweets/search").mock(