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.
- {langchain_xdataapi-0.1.0 → langchain_xdataapi-0.1.3}/PKG-INFO +3 -3
- {langchain_xdataapi-0.1.0 → langchain_xdataapi-0.1.3}/README.md +2 -2
- {langchain_xdataapi-0.1.0 → langchain_xdataapi-0.1.3}/pyproject.toml +1 -1
- {langchain_xdataapi-0.1.0 → langchain_xdataapi-0.1.3}/src/langchain_xdataapi/tools.py +76 -20
- {langchain_xdataapi-0.1.0 → langchain_xdataapi-0.1.3}/tests/test_tools.py +60 -0
- {langchain_xdataapi-0.1.0 → langchain_xdataapi-0.1.3}/.gitignore +0 -0
- {langchain_xdataapi-0.1.0 → langchain_xdataapi-0.1.3}/LICENSE +0 -0
- {langchain_xdataapi-0.1.0 → langchain_xdataapi-0.1.3}/src/langchain_xdataapi/__init__.py +0 -0
- {langchain_xdataapi-0.1.0 → langchain_xdataapi-0.1.3}/src/langchain_xdataapi/client.py +0 -0
- {langchain_xdataapi-0.1.0 → langchain_xdataapi-0.1.3}/src/langchain_xdataapi/toolkit.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: langchain-xdataapi
|
|
3
|
-
Version: 0.1.
|
|
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 |
|
|
@@ -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
|
|
59
|
-
"created date.
|
|
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(
|
|
64
|
-
|
|
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
|
|
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
|
-
"
|
|
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(
|
|
82
|
-
|
|
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(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|