exa-py 1.14.3__tar.gz → 1.14.5__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.
Potentially problematic release.
This version of exa-py might be problematic. Click here for more details.
- {exa_py-1.14.3 → exa_py-1.14.5}/PKG-INFO +1 -1
- {exa_py-1.14.3 → exa_py-1.14.5}/exa_py/api.py +43 -0
- {exa_py-1.14.3 → exa_py-1.14.5}/exa_py.egg-info/PKG-INFO +1 -1
- {exa_py-1.14.3 → exa_py-1.14.5}/exa_py.egg-info/SOURCES.txt +1 -0
- {exa_py-1.14.3 → exa_py-1.14.5}/pyproject.toml +1 -8
- {exa_py-1.14.3 → exa_py-1.14.5}/setup.py +1 -1
- exa_py-1.14.5/tests/test_search_api.py +136 -0
- {exa_py-1.14.3 → exa_py-1.14.5}/README.md +0 -0
- {exa_py-1.14.3 → exa_py-1.14.5}/exa_py/__init__.py +0 -0
- {exa_py-1.14.3 → exa_py-1.14.5}/exa_py/py.typed +0 -0
- {exa_py-1.14.3 → exa_py-1.14.5}/exa_py/research/__init__.py +0 -0
- {exa_py-1.14.3 → exa_py-1.14.5}/exa_py/research/client.py +0 -0
- {exa_py-1.14.3 → exa_py-1.14.5}/exa_py/research/models.py +0 -0
- {exa_py-1.14.3 → exa_py-1.14.5}/exa_py/utils.py +0 -0
- {exa_py-1.14.3 → exa_py-1.14.5}/exa_py/websets/__init__.py +0 -0
- {exa_py-1.14.3 → exa_py-1.14.5}/exa_py/websets/client.py +0 -0
- {exa_py-1.14.3 → exa_py-1.14.5}/exa_py/websets/core/__init__.py +0 -0
- {exa_py-1.14.3 → exa_py-1.14.5}/exa_py/websets/core/base.py +0 -0
- {exa_py-1.14.3 → exa_py-1.14.5}/exa_py/websets/enrichments/__init__.py +0 -0
- {exa_py-1.14.3 → exa_py-1.14.5}/exa_py/websets/enrichments/client.py +0 -0
- {exa_py-1.14.3 → exa_py-1.14.5}/exa_py/websets/items/__init__.py +0 -0
- {exa_py-1.14.3 → exa_py-1.14.5}/exa_py/websets/items/client.py +0 -0
- {exa_py-1.14.3 → exa_py-1.14.5}/exa_py/websets/monitors/__init__.py +0 -0
- {exa_py-1.14.3 → exa_py-1.14.5}/exa_py/websets/monitors/client.py +0 -0
- {exa_py-1.14.3 → exa_py-1.14.5}/exa_py/websets/monitors/runs/__init__.py +0 -0
- {exa_py-1.14.3 → exa_py-1.14.5}/exa_py/websets/monitors/runs/client.py +0 -0
- {exa_py-1.14.3 → exa_py-1.14.5}/exa_py/websets/searches/__init__.py +0 -0
- {exa_py-1.14.3 → exa_py-1.14.5}/exa_py/websets/searches/client.py +0 -0
- {exa_py-1.14.3 → exa_py-1.14.5}/exa_py/websets/types.py +0 -0
- {exa_py-1.14.3 → exa_py-1.14.5}/exa_py/websets/webhooks/__init__.py +0 -0
- {exa_py-1.14.3 → exa_py-1.14.5}/exa_py/websets/webhooks/client.py +0 -0
- {exa_py-1.14.3 → exa_py-1.14.5}/exa_py.egg-info/dependency_links.txt +0 -0
- {exa_py-1.14.3 → exa_py-1.14.5}/exa_py.egg-info/requires.txt +0 -0
- {exa_py-1.14.3 → exa_py-1.14.5}/exa_py.egg-info/top_level.txt +0 -0
- {exa_py-1.14.3 → exa_py-1.14.5}/setup.cfg +0 -0
- {exa_py-1.14.3 → exa_py-1.14.5}/tests/test_monitors.py +0 -0
- {exa_py-1.14.3 → exa_py-1.14.5}/tests/test_websets.py +0 -0
|
@@ -166,6 +166,7 @@ CONTENTS_OPTIONS_TYPES = {
|
|
|
166
166
|
"text": [dict, bool],
|
|
167
167
|
"highlights": [dict, bool],
|
|
168
168
|
"summary": [dict, bool],
|
|
169
|
+
"context": [dict, bool],
|
|
169
170
|
"metadata": [dict, bool],
|
|
170
171
|
"livecrawl_timeout": [int],
|
|
171
172
|
"livecrawl": [LIVECRAWL_OPTIONS],
|
|
@@ -292,6 +293,16 @@ class SummaryContentsOptions(TypedDict, total=False):
|
|
|
292
293
|
schema: JSONSchema
|
|
293
294
|
|
|
294
295
|
|
|
296
|
+
class ContextContentsOptions(TypedDict, total=False):
|
|
297
|
+
"""Options for retrieving aggregated context from a set of search results.
|
|
298
|
+
|
|
299
|
+
Attributes:
|
|
300
|
+
max_characters (int): The maximum number of characters to include in the context string.
|
|
301
|
+
"""
|
|
302
|
+
|
|
303
|
+
max_characters: int
|
|
304
|
+
|
|
305
|
+
|
|
295
306
|
class ExtrasOptions(TypedDict, total=False):
|
|
296
307
|
"""A class representing additional extraction fields (e.g. links, images)"""
|
|
297
308
|
|
|
@@ -788,6 +799,15 @@ class AsyncStreamAnswerResponse:
|
|
|
788
799
|
|
|
789
800
|
T = TypeVar("T")
|
|
790
801
|
|
|
802
|
+
@dataclass
|
|
803
|
+
class ContentStatus:
|
|
804
|
+
"""A class representing the status of a content retrieval operation."""
|
|
805
|
+
|
|
806
|
+
id: str
|
|
807
|
+
status: str
|
|
808
|
+
source: str
|
|
809
|
+
|
|
810
|
+
|
|
791
811
|
|
|
792
812
|
@dataclass
|
|
793
813
|
class SearchResponse(Generic[T]):
|
|
@@ -798,16 +818,23 @@ class SearchResponse(Generic[T]):
|
|
|
798
818
|
autoprompt_string (str, optional): The Exa query created by autoprompt.
|
|
799
819
|
resolved_search_type (str, optional): 'neural' or 'keyword' if auto.
|
|
800
820
|
auto_date (str, optional): A date for filtering if autoprompt found one.
|
|
821
|
+
context (str, optional): Combined context string when requested via contents.context.
|
|
822
|
+
statuses (List[ContentStatus], optional): Status list from get_contents.
|
|
823
|
+
cost_dollars (CostDollars, optional): Cost breakdown.
|
|
801
824
|
"""
|
|
802
825
|
|
|
803
826
|
results: List[T]
|
|
804
827
|
autoprompt_string: Optional[str]
|
|
805
828
|
resolved_search_type: Optional[str]
|
|
806
829
|
auto_date: Optional[str]
|
|
830
|
+
context: Optional[str] = None
|
|
831
|
+
statuses: Optional[List[ContentStatus]] = None
|
|
807
832
|
cost_dollars: Optional[CostDollars] = None
|
|
808
833
|
|
|
809
834
|
def __str__(self):
|
|
810
835
|
output = "\n\n".join(str(result) for result in self.results)
|
|
836
|
+
if self.context:
|
|
837
|
+
output += f"\nContext: {self.context}"
|
|
811
838
|
if self.autoprompt_string:
|
|
812
839
|
output += f"\n\nAutoprompt String: {self.autoprompt_string}"
|
|
813
840
|
if self.resolved_search_type:
|
|
@@ -818,6 +845,8 @@ class SearchResponse(Generic[T]):
|
|
|
818
845
|
output += f"\n - search: {self.cost_dollars.search}"
|
|
819
846
|
if self.cost_dollars.contents:
|
|
820
847
|
output += f"\n - contents: {self.cost_dollars.contents}"
|
|
848
|
+
if self.statuses:
|
|
849
|
+
output += f"\nStatuses: {self.statuses}"
|
|
821
850
|
return output
|
|
822
851
|
|
|
823
852
|
|
|
@@ -1242,6 +1271,7 @@ class Exa:
|
|
|
1242
1271
|
"text",
|
|
1243
1272
|
"highlights",
|
|
1244
1273
|
"summary",
|
|
1274
|
+
"context",
|
|
1245
1275
|
"subpages",
|
|
1246
1276
|
"subpage_target",
|
|
1247
1277
|
"livecrawl",
|
|
@@ -1258,6 +1288,7 @@ class Exa:
|
|
|
1258
1288
|
data["autopromptString"] if "autopromptString" in data else None,
|
|
1259
1289
|
data["resolvedSearchType"] if "resolvedSearchType" in data else None,
|
|
1260
1290
|
data["autoDate"] if "autoDate" in data else None,
|
|
1291
|
+
context=data.get("context"),
|
|
1261
1292
|
cost_dollars=cost_dollars,
|
|
1262
1293
|
)
|
|
1263
1294
|
|
|
@@ -1405,12 +1436,15 @@ class Exa:
|
|
|
1405
1436
|
options = to_camel_case(options)
|
|
1406
1437
|
data = self.request("/contents", options)
|
|
1407
1438
|
cost_dollars = parse_cost_dollars(data.get("costDollars"))
|
|
1439
|
+
statuses = [ContentStatus(**status) for status in data.get("statuses", [])]
|
|
1408
1440
|
return SearchResponse(
|
|
1409
1441
|
[Result(**to_snake_case(result)) for result in data["results"]],
|
|
1410
1442
|
data.get("autopromptString"),
|
|
1411
1443
|
data.get("resolvedSearchType"),
|
|
1412
1444
|
data.get("autoDate"),
|
|
1445
|
+
context=data.get("context"),
|
|
1413
1446
|
cost_dollars=cost_dollars,
|
|
1447
|
+
statuses=statuses,
|
|
1414
1448
|
)
|
|
1415
1449
|
|
|
1416
1450
|
def find_similar(
|
|
@@ -1700,6 +1734,7 @@ class Exa:
|
|
|
1700
1734
|
"text",
|
|
1701
1735
|
"highlights",
|
|
1702
1736
|
"summary",
|
|
1737
|
+
"context",
|
|
1703
1738
|
"subpages",
|
|
1704
1739
|
"subpage_target",
|
|
1705
1740
|
"livecrawl",
|
|
@@ -1716,6 +1751,7 @@ class Exa:
|
|
|
1716
1751
|
data.get("autopromptString"),
|
|
1717
1752
|
data.get("resolvedSearchType"),
|
|
1718
1753
|
data.get("autoDate"),
|
|
1754
|
+
context=data.get("context"),
|
|
1719
1755
|
cost_dollars=cost_dollars,
|
|
1720
1756
|
)
|
|
1721
1757
|
|
|
@@ -2052,6 +2088,7 @@ class AsyncExa(Exa):
|
|
|
2052
2088
|
"text",
|
|
2053
2089
|
"highlights",
|
|
2054
2090
|
"summary",
|
|
2091
|
+
"context",
|
|
2055
2092
|
"subpages",
|
|
2056
2093
|
"subpage_target",
|
|
2057
2094
|
"livecrawl",
|
|
@@ -2068,6 +2105,7 @@ class AsyncExa(Exa):
|
|
|
2068
2105
|
data["autopromptString"] if "autopromptString" in data else None,
|
|
2069
2106
|
data["resolvedSearchType"] if "resolvedSearchType" in data else None,
|
|
2070
2107
|
data["autoDate"] if "autoDate" in data else None,
|
|
2108
|
+
context=data.get("context"),
|
|
2071
2109
|
cost_dollars=cost_dollars,
|
|
2072
2110
|
)
|
|
2073
2111
|
|
|
@@ -2092,12 +2130,15 @@ class AsyncExa(Exa):
|
|
|
2092
2130
|
options = to_camel_case(options)
|
|
2093
2131
|
data = await self.async_request("/contents", options)
|
|
2094
2132
|
cost_dollars = parse_cost_dollars(data.get("costDollars"))
|
|
2133
|
+
statuses = [ContentStatus(**status) for status in data.get("statuses", [])]
|
|
2095
2134
|
return SearchResponse(
|
|
2096
2135
|
[Result(**to_snake_case(result)) for result in data["results"]],
|
|
2097
2136
|
data.get("autopromptString"),
|
|
2098
2137
|
data.get("resolvedSearchType"),
|
|
2099
2138
|
data.get("autoDate"),
|
|
2139
|
+
context=data.get("context"),
|
|
2100
2140
|
cost_dollars=cost_dollars,
|
|
2141
|
+
statuses=statuses,
|
|
2101
2142
|
)
|
|
2102
2143
|
|
|
2103
2144
|
async def find_similar(
|
|
@@ -2175,6 +2216,7 @@ class AsyncExa(Exa):
|
|
|
2175
2216
|
"text",
|
|
2176
2217
|
"highlights",
|
|
2177
2218
|
"summary",
|
|
2219
|
+
"context",
|
|
2178
2220
|
"subpages",
|
|
2179
2221
|
"subpage_target",
|
|
2180
2222
|
"livecrawl",
|
|
@@ -2191,6 +2233,7 @@ class AsyncExa(Exa):
|
|
|
2191
2233
|
data.get("autopromptString"),
|
|
2192
2234
|
data.get("resolvedSearchType"),
|
|
2193
2235
|
data.get("autoDate"),
|
|
2236
|
+
context=data.get("context"),
|
|
2194
2237
|
cost_dollars=cost_dollars,
|
|
2195
2238
|
)
|
|
2196
2239
|
|
|
@@ -1,10 +1,3 @@
|
|
|
1
|
-
[tool.poetry]
|
|
2
|
-
name = "exa-py"
|
|
3
|
-
version = "1.14.2"
|
|
4
|
-
description = "Python SDK for Exa API."
|
|
5
|
-
authors = ["Exa AI <hello@exa.ai>"]
|
|
6
|
-
readme = "README.md"
|
|
7
|
-
|
|
8
1
|
[tool.poetry.dependencies]
|
|
9
2
|
python = "^3.9"
|
|
10
3
|
requests = "^2.32.3"
|
|
@@ -32,7 +25,7 @@ in-project = true
|
|
|
32
25
|
|
|
33
26
|
[project]
|
|
34
27
|
name = "exa-py"
|
|
35
|
-
version = "1.14.
|
|
28
|
+
version = "1.14.5"
|
|
36
29
|
description = "Python SDK for Exa API."
|
|
37
30
|
readme = "README.md"
|
|
38
31
|
requires-python = ">=3.9"
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from unittest.mock import AsyncMock, patch
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from exa_py import Exa, AsyncExa, api as exa_api
|
|
8
|
+
|
|
9
|
+
API_KEY = os.getenv("EXA_API_KEY", "test-key")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _have_real_key() -> bool:
|
|
13
|
+
return API_KEY != "test-key" and len(API_KEY) > 10
|
|
14
|
+
|
|
15
|
+
########################################
|
|
16
|
+
# Offline unit tests (no network)
|
|
17
|
+
########################################
|
|
18
|
+
|
|
19
|
+
def test_contentstatus_parsing_offline():
|
|
20
|
+
payload_status = {"id": "u", "status": "success", "source": "cached"}
|
|
21
|
+
cs = exa_api.ContentStatus(**payload_status)
|
|
22
|
+
assert cs.id == "u" and cs.status == "success" and cs.source == "cached"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_answerresponse_accepts_dict():
|
|
26
|
+
dummy = exa_api.AnswerResult(id="1", url="u", title="t")
|
|
27
|
+
resp = exa_api.AnswerResponse(answer={"foo": "bar"}, citations=[dummy])
|
|
28
|
+
assert isinstance(resp.answer, dict) and resp.answer["foo"] == "bar"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.mark.asyncio
|
|
32
|
+
async def test_async_request_accepts_201():
|
|
33
|
+
ax = AsyncExa(API_KEY)
|
|
34
|
+
|
|
35
|
+
async def _fake_post(url, json, headers):
|
|
36
|
+
return httpx.Response(201, json={"ok": True})
|
|
37
|
+
|
|
38
|
+
with patch.object(ax.client, "post", new=AsyncMock(side_effect=_fake_post)):
|
|
39
|
+
data = await ax.async_request("/dummy", {})
|
|
40
|
+
assert data == {"ok": True}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
########################################
|
|
44
|
+
# Live integration tests (skipped without key)
|
|
45
|
+
########################################
|
|
46
|
+
|
|
47
|
+
@pytest.mark.skipif(not _have_real_key(), reason="EXA_API_KEY not provided")
|
|
48
|
+
def test_user_agent_header():
|
|
49
|
+
exa = Exa(API_KEY)
|
|
50
|
+
assert exa.headers["User-Agent"] == "exa-py 1.12.4"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pytest.mark.skipif(not _have_real_key(), reason="EXA_API_KEY not provided")
|
|
54
|
+
def test_research_client_attrs():
|
|
55
|
+
exa = Exa(API_KEY)
|
|
56
|
+
aexa = AsyncExa(API_KEY)
|
|
57
|
+
assert hasattr(exa, "research") and hasattr(aexa, "research")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ---- Core live endpoint smoke checks ----
|
|
61
|
+
|
|
62
|
+
@pytest.mark.skipif(not _have_real_key(), reason="EXA_API_KEY not provided")
|
|
63
|
+
def test_get_contents_live_preferred():
|
|
64
|
+
exa = Exa(API_KEY)
|
|
65
|
+
resp = exa.get_contents(urls=["https://techcrunch.com"], text=True, livecrawl="preferred")
|
|
66
|
+
assert isinstance(resp, exa_api.SearchResponse)
|
|
67
|
+
# statuses may be empty when cached – still fine
|
|
68
|
+
assert len(resp.results) >= 1
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@pytest.mark.skipif(not _have_real_key(), reason="EXA_API_KEY not provided")
|
|
72
|
+
def test_search_and_contents_live():
|
|
73
|
+
exa = Exa(API_KEY)
|
|
74
|
+
resp = exa.search_and_contents("openai", num_results=1, text=True)
|
|
75
|
+
assert resp.results and resp.results[0].text
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@pytest.mark.skipif(not _have_real_key(), reason="EXA_API_KEY not provided")
|
|
79
|
+
def test_find_similar_live():
|
|
80
|
+
exa = Exa(API_KEY)
|
|
81
|
+
resp = exa.find_similar("https://www.openai.com", num_results=1)
|
|
82
|
+
assert resp.results
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@pytest.mark.skipif(not _have_real_key(), reason="EXA_API_KEY not provided")
|
|
86
|
+
def test_get_contents_sync_live():
|
|
87
|
+
exa = Exa(API_KEY)
|
|
88
|
+
resp = exa.get_contents(urls=["https://example.com"], text=True, livecrawl="never")
|
|
89
|
+
assert resp.results
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@pytest.mark.asyncio
|
|
93
|
+
@pytest.mark.skipif(not _have_real_key(), reason="EXA_API_KEY not provided")
|
|
94
|
+
async def test_get_contents_async_live():
|
|
95
|
+
ax = AsyncExa(API_KEY)
|
|
96
|
+
resp = await ax.get_contents(urls=["https://example.com"], text=True, livecrawl="never")
|
|
97
|
+
assert resp.results
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# researchTask endpoint is still beta; mark as xfail if 404 returned
|
|
101
|
+
@pytest.mark.skipif(not _have_real_key(), reason="EXA_API_KEY not provided")
|
|
102
|
+
@pytest.mark.xfail(strict=False)
|
|
103
|
+
def test_research_task_live():
|
|
104
|
+
exa = Exa(API_KEY)
|
|
105
|
+
schema = {"type": "object", "properties": {"answer": {"type": "string"}}, "required": ["answer"]}
|
|
106
|
+
resp = exa.researchTask(input_instructions="Return the string 'pong'", output_schema=schema)
|
|
107
|
+
assert resp.id
|
|
108
|
+
|
|
109
|
+
########################################
|
|
110
|
+
# Live tests for new context / statuses features
|
|
111
|
+
########################################
|
|
112
|
+
|
|
113
|
+
@pytest.mark.skipif(not _have_real_key(), reason="EXA_API_KEY not provided")
|
|
114
|
+
def test_search_and_contents_context_live():
|
|
115
|
+
"""search_and_contents with context=True should return non-empty context string."""
|
|
116
|
+
exa = Exa(API_KEY)
|
|
117
|
+
resp = exa.search_and_contents("openai research", num_results=3, context=True, text=False)
|
|
118
|
+
assert resp.context is not None and isinstance(resp.context, str) and len(resp.context) > 0
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@pytest.mark.skipif(not _have_real_key(), reason="EXA_API_KEY not provided")
|
|
122
|
+
def test_find_similar_and_contents_context_live():
|
|
123
|
+
"""find_similar_and_contents with context flag should include context string."""
|
|
124
|
+
exa = Exa(API_KEY)
|
|
125
|
+
resp = exa.find_similar_and_contents("https://www.openai.com", num_results=3, context=True, text=False)
|
|
126
|
+
# context may be empty depending on backend, but attribute should exist (None or str)
|
|
127
|
+
assert hasattr(resp, "context")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@pytest.mark.skipif(not _have_real_key(), reason="EXA_API_KEY not provided")
|
|
131
|
+
def test_get_contents_statuses_live():
|
|
132
|
+
"""get_contents should expose statuses list (possibly empty)."""
|
|
133
|
+
exa = Exa(API_KEY)
|
|
134
|
+
resp = exa.get_contents(urls=["https://techcrunch.com"], text=True, livecrawl="never")
|
|
135
|
+
# statuses attribute exists; ensure it's a list
|
|
136
|
+
assert isinstance(resp.statuses, list)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|