langchain-xdataapi 0.1.1__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.1 → langchain_xdataapi-0.1.3}/PKG-INFO +1 -1
- {langchain_xdataapi-0.1.1 → langchain_xdataapi-0.1.3}/pyproject.toml +1 -1
- {langchain_xdataapi-0.1.1 → langchain_xdataapi-0.1.3}/src/langchain_xdataapi/tools.py +28 -11
- {langchain_xdataapi-0.1.1 → langchain_xdataapi-0.1.3}/tests/test_tools.py +24 -0
- {langchain_xdataapi-0.1.1 → langchain_xdataapi-0.1.3}/.gitignore +0 -0
- {langchain_xdataapi-0.1.1 → langchain_xdataapi-0.1.3}/LICENSE +0 -0
- {langchain_xdataapi-0.1.1 → langchain_xdataapi-0.1.3}/README.md +0 -0
- {langchain_xdataapi-0.1.1 → langchain_xdataapi-0.1.3}/src/langchain_xdataapi/__init__.py +0 -0
- {langchain_xdataapi-0.1.1 → langchain_xdataapi-0.1.3}/src/langchain_xdataapi/client.py +0 -0
- {langchain_xdataapi-0.1.1 → 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
|
|
@@ -7,7 +7,9 @@ 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
|
|
@@ -16,10 +18,23 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
|
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
|
|
|
@@ -82,8 +97,10 @@ class GetUser(_Base):
|
|
|
82
97
|
) -> tuple[str, dict[str, Any] | None]:
|
|
83
98
|
_one_of(handle, id, "handle or id")
|
|
84
99
|
if id is not None:
|
|
85
|
-
|
|
86
|
-
|
|
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}
|
|
87
104
|
|
|
88
105
|
|
|
89
106
|
class _UsersArgs(BaseModel):
|
|
@@ -138,7 +155,7 @@ class GetUserTweets(_Base):
|
|
|
138
155
|
def _call(
|
|
139
156
|
self, user: str, count: int | None = None, cursor: str | None = None, fresh: bool | None = None
|
|
140
157
|
) -> tuple[str, dict[str, Any] | None]:
|
|
141
|
-
return f"/v1/users/{_handle(user)}/tweets", {
|
|
158
|
+
return f"/v1/users/{_seg(_handle(user))}/tweets", {
|
|
142
159
|
"count": self.client.cap(count),
|
|
143
160
|
"cursor": cursor,
|
|
144
161
|
"fresh": fresh,
|
|
@@ -156,7 +173,7 @@ class GetFollowers(_Base):
|
|
|
156
173
|
def _call(
|
|
157
174
|
self, user: str, count: int | None = None, cursor: str | None = None, fresh: bool | None = None
|
|
158
175
|
) -> tuple[str, dict[str, Any] | None]:
|
|
159
|
-
return f"/v1/users/{_handle(user)}/followers", {
|
|
176
|
+
return f"/v1/users/{_seg(_handle(user))}/followers", {
|
|
160
177
|
"count": self.client.cap(count),
|
|
161
178
|
"cursor": cursor,
|
|
162
179
|
"fresh": fresh,
|
|
@@ -174,7 +191,7 @@ class GetFollowerIds(_Base):
|
|
|
174
191
|
def _call(
|
|
175
192
|
self, user: str, count: int | None = None, cursor: str | None = None, fresh: bool | None = None
|
|
176
193
|
) -> tuple[str, dict[str, Any] | None]:
|
|
177
|
-
return f"/v1/users/{_handle(user)}/followers/ids", {
|
|
194
|
+
return f"/v1/users/{_seg(_handle(user))}/followers/ids", {
|
|
178
195
|
"count": self.client.cap(count),
|
|
179
196
|
"cursor": cursor,
|
|
180
197
|
"fresh": fresh,
|
|
@@ -192,7 +209,7 @@ class GetFollowing(_Base):
|
|
|
192
209
|
def _call(
|
|
193
210
|
self, user: str, count: int | None = None, cursor: str | None = None, fresh: bool | None = None
|
|
194
211
|
) -> tuple[str, dict[str, Any] | None]:
|
|
195
|
-
return f"/v1/users/{_handle(user)}/following", {
|
|
212
|
+
return f"/v1/users/{_seg(_handle(user))}/following", {
|
|
196
213
|
"count": self.client.cap(count),
|
|
197
214
|
"cursor": cursor,
|
|
198
215
|
"fresh": fresh,
|
|
@@ -259,7 +276,7 @@ class GetTweet(_Base):
|
|
|
259
276
|
args_schema: type[BaseModel] = _TweetArgs
|
|
260
277
|
|
|
261
278
|
def _call(self, id: str, fresh: bool | None = None) -> tuple[str, dict[str, Any] | None]:
|
|
262
|
-
return f"/v1/tweets/{id}", {"fresh": fresh}
|
|
279
|
+
return f"/v1/tweets/{_seg(id)}", {"fresh": fresh}
|
|
263
280
|
|
|
264
281
|
|
|
265
282
|
class _TweetsArgs(BaseModel):
|
|
@@ -296,7 +313,7 @@ class GetThread(_Base):
|
|
|
296
313
|
def _call(
|
|
297
314
|
self, id: str, cursor: str | None = None, fresh: bool | None = None
|
|
298
315
|
) -> tuple[str, dict[str, Any] | None]:
|
|
299
|
-
return f"/v1/tweets/{id}/thread", {"cursor": cursor, "fresh": fresh}
|
|
316
|
+
return f"/v1/tweets/{_seg(id)}/thread", {"cursor": cursor, "fresh": fresh}
|
|
300
317
|
|
|
301
318
|
|
|
302
319
|
class GetReplies(_Base):
|
|
@@ -307,7 +324,7 @@ class GetReplies(_Base):
|
|
|
307
324
|
def _call(
|
|
308
325
|
self, id: str, cursor: str | None = None, fresh: bool | None = None
|
|
309
326
|
) -> tuple[str, dict[str, Any] | None]:
|
|
310
|
-
return f"/v1/tweets/{id}/replies", {"cursor": cursor, "fresh": fresh}
|
|
327
|
+
return f"/v1/tweets/{_seg(id)}/replies", {"cursor": cursor, "fresh": fresh}
|
|
311
328
|
|
|
312
329
|
|
|
313
330
|
class _CountedTweetArgs(BaseModel):
|
|
@@ -325,7 +342,7 @@ class GetQuotes(_Base):
|
|
|
325
342
|
def _call(
|
|
326
343
|
self, id: str, count: int | None = None, cursor: str | None = None, fresh: bool | None = None
|
|
327
344
|
) -> tuple[str, dict[str, Any] | None]:
|
|
328
|
-
return f"/v1/tweets/{id}/quotes", {
|
|
345
|
+
return f"/v1/tweets/{_seg(id)}/quotes", {
|
|
329
346
|
"count": self.client.cap(count),
|
|
330
347
|
"cursor": cursor,
|
|
331
348
|
"fresh": fresh,
|
|
@@ -343,7 +360,7 @@ class GetRetweeters(_Base):
|
|
|
343
360
|
def _call(
|
|
344
361
|
self, id: str, count: int | None = None, cursor: str | None = None, fresh: bool | None = None
|
|
345
362
|
) -> tuple[str, dict[str, Any] | None]:
|
|
346
|
-
return f"/v1/tweets/{id}/retweeters", {
|
|
363
|
+
return f"/v1/tweets/{_seg(id)}/retweeters", {
|
|
347
364
|
"count": self.client.cap(count),
|
|
348
365
|
"cursor": cursor,
|
|
349
366
|
"fresh": fresh,
|
|
@@ -53,6 +53,30 @@ def test_get_user_wants_a_handle_or_an_id_and_not_both():
|
|
|
53
53
|
tool.invoke({"id": "not-a-number"})
|
|
54
54
|
|
|
55
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
|
+
|
|
56
80
|
@respx.mock
|
|
57
81
|
def test_users_batch_by_ids_sends_ids_and_no_handles():
|
|
58
82
|
route = respx.get(f"{BASE}/v1/users/batch").mock(return_value=httpx.Response(200, json={"data": []}))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|