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.
- {langchain_xdataapi-0.1.0 → langchain_xdataapi-0.1.1}/PKG-INFO +3 -3
- {langchain_xdataapi-0.1.0 → langchain_xdataapi-0.1.1}/README.md +2 -2
- {langchain_xdataapi-0.1.0 → langchain_xdataapi-0.1.1}/pyproject.toml +1 -1
- {langchain_xdataapi-0.1.0 → langchain_xdataapi-0.1.1}/src/langchain_xdataapi/tools.py +50 -11
- {langchain_xdataapi-0.1.0 → langchain_xdataapi-0.1.1}/tests/test_tools.py +36 -0
- {langchain_xdataapi-0.1.0 → langchain_xdataapi-0.1.1}/.gitignore +0 -0
- {langchain_xdataapi-0.1.0 → langchain_xdataapi-0.1.1}/LICENSE +0 -0
- {langchain_xdataapi-0.1.0 → langchain_xdataapi-0.1.1}/src/langchain_xdataapi/__init__.py +0 -0
- {langchain_xdataapi-0.1.0 → langchain_xdataapi-0.1.1}/src/langchain_xdataapi/client.py +0 -0
- {langchain_xdataapi-0.1.0 → langchain_xdataapi-0.1.1}/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.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 |
|
|
@@ -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
|
|
59
|
-
"created date.
|
|
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(
|
|
64
|
-
|
|
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
|
|
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
|
-
"
|
|
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(
|
|
82
|
-
|
|
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(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|