langchain-xdataapi 0.1.0__tar.gz → 0.1.1__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.1
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.1"
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"
@@ -11,7 +11,7 @@ from typing import Any, Literal, Sequence
11
11
 
12
12
  from langchain_core.callbacks import AsyncCallbackManagerForToolRun, CallbackManagerForToolRun
13
13
  from langchain_core.tools import BaseTool
14
- from pydantic import BaseModel, ConfigDict, Field
14
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
15
15
 
16
16
  from .client import XdataapiClient
17
17
 
@@ -47,39 +47,78 @@ class _Base(BaseTool):
47
47
  # --- users ---------------------------------------------------------------
48
48
 
49
49
 
50
+ def _one_of(first: Any, second: Any, names: str) -> None:
51
+ """A user is named by handle or by id, never by both and never by neither."""
52
+ if (first is None) == (second is None):
53
+ raise ValueError(f"give {names}, one of the two")
54
+
55
+
50
56
  class _UserArgs(BaseModel):
51
- handle: str = Field(description="X handle, with or without the leading @.")
57
+ handle: str | None = Field(default=None, description="X handle, with or without the leading @.")
58
+ id: str | None = Field(
59
+ default=None,
60
+ pattern=r"^\d{1,19}$",
61
+ description="Numeric user id. It stays the same when the user changes the handle.",
62
+ )
52
63
  fresh: bool | None = Field(default=None, description="Skip the cache. Costs double.")
53
64
 
65
+ @model_validator(mode="after")
66
+ def _handle_or_id(self) -> "_UserArgs":
67
+ _one_of(self.handle, self.id, "handle or id")
68
+ return self
69
+
54
70
 
55
71
  class GetUser(_Base):
56
72
  name: str = "x_get_user"
57
73
  description: str = (
58
- "Public profile of one X (Twitter) user by handle: bio, counts, verified state, "
59
- "created date. Costs 1 credit."
74
+ "Public profile of one X (Twitter) user, by handle or by numeric user id (give one of "
75
+ "the two): bio, counts, verified state, created date. The id stays the same when the "
76
+ "user changes the handle. Costs 1 credit."
60
77
  )
61
78
  args_schema: type[BaseModel] = _UserArgs
62
79
 
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}
80
+ def _call(
81
+ self, handle: str | None = None, id: str | None = None, fresh: bool | None = None
82
+ ) -> tuple[str, dict[str, Any] | None]:
83
+ _one_of(handle, id, "handle or id")
84
+ 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}
65
87
 
66
88
 
67
89
  class _UsersArgs(BaseModel):
68
- handles: Sequence[str] = Field(description="Up to 100 handles in one call.")
90
+ handles: Sequence[str] | None = Field(default=None, description="Up to 100 handles in one call.")
91
+ ids: Sequence[str] | None = Field(
92
+ default=None, description="Up to 100 numeric user ids in one call, in place of handles."
93
+ )
69
94
  fresh: bool | None = Field(default=None, description="Skip the cache. Costs double.")
70
95
 
96
+ @model_validator(mode="after")
97
+ def _handles_or_ids(self) -> "_UsersArgs":
98
+ _one_of(self.handles, self.ids, "handles or ids")
99
+ return self
100
+
71
101
 
72
102
  class GetUsers(_Base):
73
103
  name: str = "x_get_users"
74
104
  description: str = (
75
- "Profiles of up to 100 X users in ONE call. Prefer this over repeated single reads: "
105
+ "Profiles of up to 100 X users in ONE call, by handle (handles) or by numeric user id "
106
+ "(ids); give one of the two lists. Prefer this over repeated single reads: "
76
107
  "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."
108
+ "inputs that do not exist are listed in errors and cost nothing."
78
109
  )
79
110
  args_schema: type[BaseModel] = _UsersArgs
80
111
 
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}
112
+ def _call(
113
+ self,
114
+ handles: Sequence[str] | None = None,
115
+ ids: Sequence[str] | None = None,
116
+ fresh: bool | None = None,
117
+ ) -> tuple[str, dict[str, Any] | None]:
118
+ _one_of(handles, ids, "handles or ids")
119
+ if ids is not None:
120
+ return "/v1/users/batch", {"ids": ",".join(i.strip() for i in ids), "fresh": fresh}
121
+ return "/v1/users/batch", {"handles": ",".join(_handle(h) for h in handles or []), "fresh": fresh}
83
122
 
84
123
 
85
124
  class _PageArgs(BaseModel):
@@ -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,41 @@ 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_users_batch_by_ids_sends_ids_and_no_handles():
58
+ route = respx.get(f"{BASE}/v1/users/batch").mock(return_value=httpx.Response(200, json={"data": []}))
59
+ GetUsers(client=client()).invoke({"ids": ["783214", "13334762"]})
60
+ params = route.calls.last.request.url.params
61
+ assert params["ids"] == "783214,13334762"
62
+ assert "handles" not in params
63
+ GetUsers(client=client()).invoke({"handles": ["@x", "github"]})
64
+ params = route.calls.last.request.url.params
65
+ assert params["handles"] == "x,github"
66
+ assert "ids" not in params
67
+ with pytest.raises(Exception, match="handles or ids"):
68
+ GetUsers(client=client()).invoke({"handles": ["x"], "ids": ["1"]})
69
+
70
+
35
71
  @respx.mock
36
72
  def test_search_passes_the_operators_through_untouched():
37
73
  route = respx.get(f"{BASE}/v1/tweets/search").mock(