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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: langchain-xdataapi
3
- Version: 0.1.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "langchain-xdataapi"
3
- version = "0.1.1"
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,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
- return f"/v1/users/id/{id.strip()}", {"fresh": fresh}
86
- return f"/v1/users/{_handle(handle or '')}", {"fresh": fresh}
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": []}))