exa-py 1.12.1__tar.gz → 1.13.0__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.

Files changed (29) hide show
  1. exa_py-1.12.1/README.md → exa_py-1.13.0/PKG-INFO +23 -15
  2. exa_py-1.13.0/README.md +76 -0
  3. {exa_py-1.12.1 → exa_py-1.13.0}/exa_py/api.py +94 -0
  4. {exa_py-1.12.1 → exa_py-1.13.0/exa_py.egg-info}/PKG-INFO +17 -33
  5. exa_py-1.13.0/exa_py.egg-info/SOURCES.txt +26 -0
  6. exa_py-1.13.0/exa_py.egg-info/dependency_links.txt +1 -0
  7. exa_py-1.13.0/exa_py.egg-info/requires.txt +6 -0
  8. exa_py-1.13.0/exa_py.egg-info/top_level.txt +1 -0
  9. {exa_py-1.12.1 → exa_py-1.13.0}/pyproject.toml +2 -2
  10. exa_py-1.13.0/setup.cfg +4 -0
  11. exa_py-1.13.0/setup.py +26 -0
  12. exa_py-1.13.0/tests/test_websets.py +415 -0
  13. exa_py-1.12.1/exa_py/websets/_generator/pydantic/BaseModel.jinja2 +0 -42
  14. {exa_py-1.12.1 → exa_py-1.13.0}/exa_py/__init__.py +0 -0
  15. {exa_py-1.12.1 → exa_py-1.13.0}/exa_py/py.typed +0 -0
  16. {exa_py-1.12.1 → exa_py-1.13.0}/exa_py/utils.py +0 -0
  17. {exa_py-1.12.1 → exa_py-1.13.0}/exa_py/websets/__init__.py +0 -0
  18. {exa_py-1.12.1 → exa_py-1.13.0}/exa_py/websets/client.py +0 -0
  19. {exa_py-1.12.1 → exa_py-1.13.0}/exa_py/websets/core/__init__.py +0 -0
  20. {exa_py-1.12.1 → exa_py-1.13.0}/exa_py/websets/core/base.py +0 -0
  21. {exa_py-1.12.1 → exa_py-1.13.0}/exa_py/websets/enrichments/__init__.py +0 -0
  22. {exa_py-1.12.1 → exa_py-1.13.0}/exa_py/websets/enrichments/client.py +0 -0
  23. {exa_py-1.12.1 → exa_py-1.13.0}/exa_py/websets/items/__init__.py +0 -0
  24. {exa_py-1.12.1 → exa_py-1.13.0}/exa_py/websets/items/client.py +0 -0
  25. {exa_py-1.12.1 → exa_py-1.13.0}/exa_py/websets/searches/__init__.py +0 -0
  26. {exa_py-1.12.1 → exa_py-1.13.0}/exa_py/websets/searches/client.py +0 -0
  27. {exa_py-1.12.1 → exa_py-1.13.0}/exa_py/websets/types.py +0 -0
  28. {exa_py-1.12.1 → exa_py-1.13.0}/exa_py/websets/webhooks/__init__.py +0 -0
  29. {exa_py-1.12.1 → exa_py-1.13.0}/exa_py/websets/webhooks/client.py +0 -0
@@ -1,3 +1,22 @@
1
+ Metadata-Version: 2.4
2
+ Name: exa-py
3
+ Version: 1.13.0
4
+ Summary: Python SDK for Exa API.
5
+ Home-page: https://github.com/exa-labs/exa-py
6
+ Author: Exa
7
+ Author-email: Exa AI <hello@exa.ai>
8
+ License: MIT
9
+ Requires-Python: >=3.9
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: requests>=2.32.3
12
+ Requires-Dist: typing-extensions>=4.12.2
13
+ Requires-Dist: openai>=1.48
14
+ Requires-Dist: pydantic>=2.10.6
15
+ Requires-Dist: pytest-mock>=3.14.0
16
+ Requires-Dist: httpx>=0.28.1
17
+ Dynamic: author
18
+ Dynamic: home-page
19
+
1
20
  # Exa
2
21
 
3
22
  Exa (formerly Metaphor) API in Python
@@ -27,9 +46,6 @@ exa = Exa(api_key="your-api-key")
27
46
  # basic search
28
47
  results = exa.search("This is a Exa query:")
29
48
 
30
- # autoprompted search
31
- results = exa.search("autopromptable query", use_autoprompt=True)
32
-
33
49
  # keyword search (non-neural)
34
50
  results = exa.search("Google-style query", type="keyword")
35
51
 
@@ -42,13 +58,9 @@ exa = Exa(api_key="your-api-key")
42
58
  # search and get text contents
43
59
  results = exa.search_and_contents("This is a Exa query:")
44
60
 
45
- # search and get highlights
46
- results = exa.search_and_contents("This is a Exa query:", highlights=True)
47
-
48
61
  # search and get contents with contents options
49
62
  results = exa.search_and_contents("This is a Exa query:",
50
- text={"include_html_tags": True, "max_characters": 1000},
51
- highlights={"highlights_per_url": 2, "num_sentences": 1, "query": "This is the highlight query:"})
63
+ text={"include_html_tags": True, "max_characters": 1000})
52
64
 
53
65
  # find similar documents
54
66
  results = exa.find_similar("https://example.com")
@@ -57,18 +69,14 @@ exa = Exa(api_key="your-api-key")
57
69
  results = exa.find_similar("https://example.com", exclude_source_domain=True)
58
70
 
59
71
  # find similar with contents
60
- results = exa.find_similar_and_contents("https://example.com", text=True, highlights=True)
72
+ results = exa.find_similar_and_contents("https://example.com", text=True)
61
73
 
62
74
  # get text contents
63
- results = exa.get_contents(["urls"])
64
-
65
- # get highlights
66
- results = exa.get_contents(["urls"], highlights=True)
75
+ results = exa.get_contents(["tesla.com"])
67
76
 
68
77
  # get contents with contents options
69
78
  results = exa.get_contents(["urls"],
70
- text={"include_html_tags": True, "max_characters": 1000},
71
- highlights={"highlights_per_url": 2, "num_sentences": 1, "query": "This is the highlight query:"})
79
+ text={"include_html_tags": True, "max_characters": 1000})
72
80
 
73
81
  # basic answer
74
82
  response = exa.answer("This is a query to answer a question")
@@ -0,0 +1,76 @@
1
+ # Exa
2
+
3
+ Exa (formerly Metaphor) API in Python
4
+
5
+ Note: This API is basically the same as `metaphor-python` but reflects new
6
+ features associated with Metaphor's rename to Exa. New site is https://exa.ai
7
+
8
+ ## Installation
9
+
10
+ ```bash
11
+ pip install exa_py
12
+ ```
13
+
14
+ ## Usage
15
+
16
+ Import the package and initialize the Exa client with your API key:
17
+
18
+ ```python
19
+ from exa_py import Exa
20
+
21
+ exa = Exa(api_key="your-api-key")
22
+ ```
23
+
24
+ ## Common requests
25
+ ```python
26
+
27
+ # basic search
28
+ results = exa.search("This is a Exa query:")
29
+
30
+ # keyword search (non-neural)
31
+ results = exa.search("Google-style query", type="keyword")
32
+
33
+ # search with date filters
34
+ results = exa.search("This is a Exa query:", start_published_date="2019-01-01", end_published_date="2019-01-31")
35
+
36
+ # search with domain filters
37
+ results = exa.search("This is a Exa query:", include_domains=["www.cnn.com", "www.nytimes.com"])
38
+
39
+ # search and get text contents
40
+ results = exa.search_and_contents("This is a Exa query:")
41
+
42
+ # search and get contents with contents options
43
+ results = exa.search_and_contents("This is a Exa query:",
44
+ text={"include_html_tags": True, "max_characters": 1000})
45
+
46
+ # find similar documents
47
+ results = exa.find_similar("https://example.com")
48
+
49
+ # find similar excluding source domain
50
+ results = exa.find_similar("https://example.com", exclude_source_domain=True)
51
+
52
+ # find similar with contents
53
+ results = exa.find_similar_and_contents("https://example.com", text=True)
54
+
55
+ # get text contents
56
+ results = exa.get_contents(["tesla.com"])
57
+
58
+ # get contents with contents options
59
+ results = exa.get_contents(["urls"],
60
+ text={"include_html_tags": True, "max_characters": 1000})
61
+
62
+ # basic answer
63
+ response = exa.answer("This is a query to answer a question")
64
+
65
+ # answer with full text, using the exa-pro model (sends 2 expanded quries to exa search)
66
+ response = exa.answer("This is a query to answer a question", text=True, model="exa-pro")
67
+
68
+ # answer with streaming
69
+ response = exa.stream_answer("This is a query to answer:")
70
+
71
+ # Print each chunk as it arrives when using the stream_answer method
72
+ for chunk in response:
73
+ print(chunk, end='', flush=True)
74
+
75
+ ```
76
+
@@ -834,6 +834,36 @@ def nest_fields(original_dict: Dict, fields_to_nest: List[str], new_key: str):
834
834
 
835
835
  return original_dict
836
836
 
837
+ @dataclass
838
+ class ResearchTaskResponse:
839
+ """A class representing the response for a research task.
840
+
841
+ Attributes:
842
+ id (str): The unique identifier for the research request.
843
+ status (str): Status of the research request.
844
+ output (Optional[Dict[str, Any]]): The answer structured as JSON, if available.
845
+ citations (Optional[Dict[str, List[_Result]]]): List of citations used to generate the answer, grouped by root field in the output schema.
846
+ """
847
+
848
+ id: str
849
+ status: str
850
+ output: Optional[Dict[str, Any]]
851
+ citations: Dict[str, List[_Result]]
852
+
853
+ def __str__(self):
854
+ output_repr = (
855
+ json.dumps(self.output, indent=2, ensure_ascii=False)
856
+ if self.output is not None
857
+ else "None"
858
+ )
859
+ citations_str = "\n\n".join(str(src) for src in self.citations)
860
+ return (
861
+ f"ID: {self.id}\n"
862
+ f"Status: {self.status}\n"
863
+ f"Output: {output_repr}\n\n"
864
+ f"Citations:\n{citations_str}"
865
+ )
866
+
837
867
 
838
868
  class Exa:
839
869
  """A client for interacting with Exa API."""
@@ -1911,6 +1941,37 @@ class Exa:
1911
1941
  raw_response = self.request("/answer", options)
1912
1942
  return StreamAnswerResponse(raw_response)
1913
1943
 
1944
+ def researchTask(
1945
+ self,
1946
+ *,
1947
+ input_instructions: str,
1948
+ output_schema: Dict[str, Any],
1949
+ ) -> ResearchTaskResponse:
1950
+ """Submit a research request to Exa.
1951
+
1952
+ Args:
1953
+ input_instructions (str): The instructions for the research task.
1954
+ output_schema (Dict[str, Any]): JSON schema describing the desired answer structure.
1955
+ """
1956
+ # Build the request payload expected by the Exa API
1957
+ options = {
1958
+ "input": {"instructions": input_instructions},
1959
+ "output": {"schema": output_schema},
1960
+ }
1961
+
1962
+ response = self.request("/research/tasks", options)
1963
+
1964
+ return ResearchTaskResponse(
1965
+ id=response["id"],
1966
+ status=response["status"],
1967
+ output=response.get("output"),
1968
+ citations={
1969
+ key: [_Result(**to_snake_case(citation)) for citation in citations_list]
1970
+ for key, citations_list in response.get("citations", {}).items()
1971
+ },
1972
+ )
1973
+
1974
+
1914
1975
  class AsyncExa(Exa):
1915
1976
  def __init__(self, api_key: str, api_base: str = "https://api.exa.ai"):
1916
1977
  super().__init__(api_key, api_base)
@@ -2244,3 +2305,36 @@ class AsyncExa(Exa):
2244
2305
  options["stream"] = True
2245
2306
  raw_response = await self.async_request("/answer", options)
2246
2307
  return AsyncStreamAnswerResponse(raw_response)
2308
+
2309
+ async def researchTask(
2310
+ self,
2311
+ *,
2312
+ input_instructions: str,
2313
+ output_schema: Dict[str, Any],
2314
+ ) -> ResearchTaskResponse:
2315
+ """Asynchronously submit a research request to Exa.
2316
+
2317
+ Args:
2318
+ input_instructions (str): The instructions for the research task.
2319
+ output_schema (Dict[str, Any]): JSON schema describing the desired answer structure.
2320
+
2321
+ Returns:
2322
+ ResearchTaskResponse: The parsed response from the Exa API.
2323
+ """
2324
+ # Build the request payload expected by the Exa API
2325
+ options = {
2326
+ "input": {"instructions": input_instructions},
2327
+ "output": {"schema": output_schema},
2328
+ }
2329
+
2330
+ response = await self.async_request("/research/tasks", options)
2331
+
2332
+ return ResearchTaskResponse(
2333
+ id=response["id"],
2334
+ status=response["status"],
2335
+ output=response.get("output"),
2336
+ citations={
2337
+ key: [_Result(**to_snake_case(citation)) for citation in citations_list]
2338
+ for key, citations_list in response.get("citations", {}).items()
2339
+ },
2340
+ )
@@ -1,25 +1,21 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: exa-py
3
- Version: 1.12.1
3
+ Version: 1.13.0
4
4
  Summary: Python SDK for Exa API.
5
+ Home-page: https://github.com/exa-labs/exa-py
6
+ Author: Exa
7
+ Author-email: Exa AI <hello@exa.ai>
5
8
  License: MIT
6
- Author: Exa AI
7
- Author-email: hello@exa.ai
8
9
  Requires-Python: >=3.9
9
- Classifier: License :: OSI Approved :: MIT License
10
- Classifier: Programming Language :: Python :: 3
11
- Classifier: Programming Language :: Python :: 3.9
12
- Classifier: Programming Language :: Python :: 3.10
13
- Classifier: Programming Language :: Python :: 3.11
14
- Classifier: Programming Language :: Python :: 3.12
15
- Classifier: Programming Language :: Python :: 3.13
16
- Requires-Dist: httpx (>=0.28.1)
17
- Requires-Dist: openai (>=1.48)
18
- Requires-Dist: pydantic (>=2.10.6)
19
- Requires-Dist: pytest-mock (>=3.14.0)
20
- Requires-Dist: requests (>=2.32.3)
21
- Requires-Dist: typing-extensions (>=4.12.2)
22
10
  Description-Content-Type: text/markdown
11
+ Requires-Dist: requests>=2.32.3
12
+ Requires-Dist: typing-extensions>=4.12.2
13
+ Requires-Dist: openai>=1.48
14
+ Requires-Dist: pydantic>=2.10.6
15
+ Requires-Dist: pytest-mock>=3.14.0
16
+ Requires-Dist: httpx>=0.28.1
17
+ Dynamic: author
18
+ Dynamic: home-page
23
19
 
24
20
  # Exa
25
21
 
@@ -50,9 +46,6 @@ exa = Exa(api_key="your-api-key")
50
46
  # basic search
51
47
  results = exa.search("This is a Exa query:")
52
48
 
53
- # autoprompted search
54
- results = exa.search("autopromptable query", use_autoprompt=True)
55
-
56
49
  # keyword search (non-neural)
57
50
  results = exa.search("Google-style query", type="keyword")
58
51
 
@@ -65,13 +58,9 @@ exa = Exa(api_key="your-api-key")
65
58
  # search and get text contents
66
59
  results = exa.search_and_contents("This is a Exa query:")
67
60
 
68
- # search and get highlights
69
- results = exa.search_and_contents("This is a Exa query:", highlights=True)
70
-
71
61
  # search and get contents with contents options
72
62
  results = exa.search_and_contents("This is a Exa query:",
73
- text={"include_html_tags": True, "max_characters": 1000},
74
- highlights={"highlights_per_url": 2, "num_sentences": 1, "query": "This is the highlight query:"})
63
+ text={"include_html_tags": True, "max_characters": 1000})
75
64
 
76
65
  # find similar documents
77
66
  results = exa.find_similar("https://example.com")
@@ -80,18 +69,14 @@ exa = Exa(api_key="your-api-key")
80
69
  results = exa.find_similar("https://example.com", exclude_source_domain=True)
81
70
 
82
71
  # find similar with contents
83
- results = exa.find_similar_and_contents("https://example.com", text=True, highlights=True)
72
+ results = exa.find_similar_and_contents("https://example.com", text=True)
84
73
 
85
74
  # get text contents
86
- results = exa.get_contents(["urls"])
87
-
88
- # get highlights
89
- results = exa.get_contents(["urls"], highlights=True)
75
+ results = exa.get_contents(["tesla.com"])
90
76
 
91
77
  # get contents with contents options
92
78
  results = exa.get_contents(["urls"],
93
- text={"include_html_tags": True, "max_characters": 1000},
94
- highlights={"highlights_per_url": 2, "num_sentences": 1, "query": "This is the highlight query:"})
79
+ text={"include_html_tags": True, "max_characters": 1000})
95
80
 
96
81
  # basic answer
97
82
  response = exa.answer("This is a query to answer a question")
@@ -108,4 +93,3 @@ exa = Exa(api_key="your-api-key")
108
93
 
109
94
  ```
110
95
 
111
-
@@ -0,0 +1,26 @@
1
+ README.md
2
+ pyproject.toml
3
+ setup.py
4
+ exa_py/__init__.py
5
+ exa_py/api.py
6
+ exa_py/py.typed
7
+ exa_py/utils.py
8
+ exa_py.egg-info/PKG-INFO
9
+ exa_py.egg-info/SOURCES.txt
10
+ exa_py.egg-info/dependency_links.txt
11
+ exa_py.egg-info/requires.txt
12
+ exa_py.egg-info/top_level.txt
13
+ exa_py/websets/__init__.py
14
+ exa_py/websets/client.py
15
+ exa_py/websets/types.py
16
+ exa_py/websets/core/__init__.py
17
+ exa_py/websets/core/base.py
18
+ exa_py/websets/enrichments/__init__.py
19
+ exa_py/websets/enrichments/client.py
20
+ exa_py/websets/items/__init__.py
21
+ exa_py/websets/items/client.py
22
+ exa_py/websets/searches/__init__.py
23
+ exa_py/websets/searches/client.py
24
+ exa_py/websets/webhooks/__init__.py
25
+ exa_py/websets/webhooks/client.py
26
+ tests/test_websets.py
@@ -0,0 +1,6 @@
1
+ requests>=2.32.3
2
+ typing-extensions>=4.12.2
3
+ openai>=1.48
4
+ pydantic>=2.10.6
5
+ pytest-mock>=3.14.0
6
+ httpx>=0.28.1
@@ -0,0 +1 @@
1
+ exa_py
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "exa-py"
3
- version = "1.12.1"
3
+ version = "1.13.0"
4
4
  description = "Python SDK for Exa API."
5
5
  authors = ["Exa AI <hello@exa.ai>"]
6
6
  readme = "README.md"
@@ -32,7 +32,7 @@ in-project = true
32
32
 
33
33
  [project]
34
34
  name = "exa-py"
35
- version = "1.12.1"
35
+ version = "1.13.0"
36
36
  description = "Python SDK for Exa API."
37
37
  readme = "README.md"
38
38
  requires-python = ">=3.9"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
exa_py-1.13.0/setup.py ADDED
@@ -0,0 +1,26 @@
1
+ from setuptools import find_packages, setup
2
+
3
+ setup(
4
+ name="exa_py",
5
+ version="1.13.0",
6
+ description="Python SDK for Exa API.",
7
+ long_description_content_type="text/markdown",
8
+ long_description=open("README.md").read(),
9
+ author="Exa",
10
+ author_email="hello@exa.ai",
11
+ package_data={"exa_py": ["py.typed"]},
12
+ url="https://github.com/exa-labs/exa-py",
13
+ packages=find_packages(),
14
+ install_requires=["requests", "typing-extensions", "openai>=1.10.0"],
15
+ classifiers=[
16
+ "Development Status :: 5 - Production/Stable",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Typing :: Typed",
20
+ "Programming Language :: Python :: 3.8",
21
+ "Programming Language :: Python :: 3.9",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ ],
26
+ )
@@ -0,0 +1,415 @@
1
+ from datetime import datetime
2
+ import json
3
+ from typing import Dict, Any
4
+
5
+ from pydantic import AnyUrl
6
+ import pytest
7
+ from unittest.mock import MagicMock
8
+
9
+ from exa_py.websets.client import WebsetsClient
10
+ from exa_py.websets.core.base import WebsetsBaseClient
11
+ from exa_py.api import snake_to_camel, camel_to_snake, to_camel_case, to_snake_case
12
+ from exa_py.websets.types import (
13
+ UpdateWebsetRequest,
14
+ CreateWebsetParameters,
15
+ Search,
16
+ CreateEnrichmentParameters,
17
+ Format
18
+ )
19
+
20
+ # ============================================================================
21
+ # Fixtures
22
+ # ============================================================================
23
+
24
+ @pytest.fixture
25
+ def mock_response():
26
+ """Factory fixture to create mock responses with custom data."""
27
+ def _create_response(json_data: Dict[str, Any], status_code: int = 200) -> MagicMock:
28
+ mock = MagicMock()
29
+ mock.json_data = json_data
30
+ mock.status_code = status_code
31
+ mock.text = json.dumps(json_data)
32
+ mock.json.return_value = json_data
33
+ return mock
34
+ return _create_response
35
+
36
+ @pytest.fixture
37
+ def parent_mock():
38
+ """Create a mock parent client."""
39
+ return MagicMock()
40
+
41
+ @pytest.fixture
42
+ def base_client(parent_mock):
43
+ """Create a base client instance with mock parent."""
44
+ return WebsetsBaseClient(parent_mock)
45
+
46
+ @pytest.fixture
47
+ def websets_client(parent_mock):
48
+ """Create a WebsetsClient instance with mock parent."""
49
+ return WebsetsClient(parent_mock)
50
+
51
+ @pytest.fixture
52
+ def items_client(websets_client):
53
+ """Create an items client instance."""
54
+ return websets_client.items
55
+
56
+ # ============================================================================
57
+ # Case Conversion Tests
58
+ # ============================================================================
59
+
60
+ @pytest.mark.parametrize("input,expected", [
61
+ ("test_case", "testCase"),
62
+ ("multiple_word_test", "multipleWordTest"),
63
+ ("single", "single"),
64
+ ("schema_", "$schema"),
65
+ ("not_", "not"),
66
+ ])
67
+ def test_snake_to_camel(input, expected):
68
+ """Test snake_case to camelCase conversion."""
69
+ assert snake_to_camel(input) == expected
70
+
71
+ @pytest.mark.parametrize("input,expected", [
72
+ ("testCase", "test_case"),
73
+ ("multipleWordTest", "multiple_word_test"),
74
+ ("single", "single"),
75
+ ])
76
+ def test_camel_to_snake(input, expected):
77
+ """Test camelCase to snake_case conversion."""
78
+ assert camel_to_snake(input) == expected
79
+
80
+ def test_dict_to_camel_case():
81
+ """Test converting dictionary keys from snake_case to camelCase."""
82
+ snake_dict = {
83
+ "test_key": "value",
84
+ "nested_dict": {
85
+ "inner_key": 123,
86
+ "another_key": True
87
+ },
88
+ "normal_key": None
89
+ }
90
+
91
+ expected = {
92
+ "testKey": "value",
93
+ "nestedDict": {
94
+ "innerKey": 123,
95
+ "anotherKey": True
96
+ }
97
+ }
98
+
99
+ assert to_camel_case(snake_dict) == expected
100
+
101
+ def test_dict_to_snake_case():
102
+ """Test converting dictionary keys from camelCase to snake_case."""
103
+ camel_dict = {
104
+ "testKey": "value",
105
+ "nestedDict": {
106
+ "innerKey": 123,
107
+ "anotherKey": True
108
+ }
109
+ }
110
+
111
+ expected = {
112
+ "test_key": "value",
113
+ "nested_dict": {
114
+ "inner_key": 123,
115
+ "another_key": True
116
+ }
117
+ }
118
+
119
+ assert to_snake_case(camel_dict) == expected
120
+
121
+ def test_request_body_case_conversion(websets_client, parent_mock):
122
+ """Test that request body fields are converted from snake_case to camelCase."""
123
+ mock_response = {
124
+ "id": "ws_123",
125
+ "object": "webset",
126
+ "status": "idle",
127
+ "externalId": "test-id",
128
+ "createdAt": "2023-01-01T00:00:00Z",
129
+ "updatedAt": "2023-01-01T00:00:00Z",
130
+ "searches": [],
131
+ "enrichments": []
132
+ }
133
+
134
+ parent_mock.request.return_value = mock_response
135
+
136
+ request = CreateWebsetParameters(
137
+ external_id="test-id",
138
+ search=Search(
139
+ query="test query",
140
+ count=10
141
+ ),
142
+ metadata={"snake_case_key": "value"}
143
+ )
144
+
145
+ websets_client.create(params=request)
146
+
147
+ actual_data = parent_mock.request.call_args[1]['data']
148
+ assert actual_data == {
149
+ "search": {
150
+ "query": "test query",
151
+ "count": 10
152
+ },
153
+ "externalId": "test-id", # This should be camelCase in the request
154
+ "metadata": {"snake_case_key": "value"} # metadata preserved original case
155
+ }
156
+
157
+ def test_response_case_conversion(websets_client, parent_mock):
158
+ """Test that API response fields are converted from camelCase to snake_case."""
159
+ mock_response = {
160
+ "id": "ws_123",
161
+ "object": "webset",
162
+ "status": "idle",
163
+ "externalId": "test-id",
164
+ "createdAt": "2023-01-01T00:00:00Z",
165
+ "updatedAt": "2023-01-01T00:00:00Z",
166
+ "searches": [],
167
+ "enrichments": []
168
+ }
169
+
170
+ parent_mock.request.return_value = mock_response
171
+ result = websets_client.get(id="ws_123")
172
+
173
+ assert result.external_id == "test-id"
174
+ assert result.created_at == datetime.fromisoformat(mock_response["createdAt"])
175
+
176
+
177
+ def test_metadata_case_preservation(websets_client, parent_mock):
178
+ """Test that metadata keys preserve their original case format when sent to API."""
179
+ test_cases = [
180
+ {"snake_case_key": "value"},
181
+ {"camelCaseKey": "value"},
182
+ {"UPPER_CASE": "value"},
183
+ {"mixed_Case_Key": "value"},
184
+ ]
185
+
186
+ for metadata in test_cases:
187
+ mock_response = {
188
+ "id": "ws_123",
189
+ "object": "webset",
190
+ "status": "idle",
191
+ "metadata": metadata,
192
+ "externalId": "test-id",
193
+ "searches": [],
194
+ "enrichments": [],
195
+ "createdAt": "2023-01-01T00:00:00Z",
196
+ "updatedAt": "2023-01-01T00:00:00Z"
197
+ }
198
+
199
+ parent_mock.request.return_value = mock_response
200
+
201
+ request = UpdateWebsetRequest(metadata=metadata)
202
+ result = websets_client.update(id="ws_123", params=request)
203
+
204
+ actual_data = parent_mock.request.call_args[1]['data']
205
+ assert actual_data["metadata"] == metadata
206
+
207
+ assert result.metadata == metadata
208
+
209
+ def test_nested_property_case_conversion(items_client, parent_mock):
210
+ """Test that nested property fields follow proper case conversion rules."""
211
+ mock_response = {
212
+ "data": [{
213
+ "id": "item_123",
214
+ "object": "webset_item",
215
+ "source": "search",
216
+ "sourceId": "search_123",
217
+ "websetId": "ws_123",
218
+ "properties": {
219
+ "type": "company",
220
+ "url": "https://example.com",
221
+ "description": "This is a test description",
222
+ "company": {
223
+ "name": "Example Company",
224
+ "logoUrl": "https://example.com/logo.png",
225
+ }
226
+ },
227
+ "evaluations": [],
228
+ "enrichments": [],
229
+ "createdAt": "2023-01-01T00:00:00Z",
230
+ "updatedAt": "2023-01-01T00:00:00Z"
231
+ }],
232
+ "hasMore": False,
233
+ "nextCursor": None
234
+ }
235
+
236
+ parent_mock.request.return_value = mock_response
237
+ result = items_client.list(webset_id="ws_123", limit=10)
238
+
239
+ assert result.data[0].properties.company.logo_url == AnyUrl("https://example.com/logo.png")
240
+
241
+ def test_request_forwards_to_parent(base_client, parent_mock):
242
+ """Test that BaseClient.request forwards to the parent client's request method."""
243
+ parent_mock.request.return_value = {"key": "value"}
244
+
245
+ result = base_client.request(
246
+ "/test",
247
+ data={"param": "value"},
248
+ method="POST",
249
+ params={"query": "test"}
250
+ )
251
+
252
+ # WebsetsBaseClient prepends '/websets/' to all endpoints
253
+ parent_mock.request.assert_called_once_with(
254
+ "/websets//test", # Double slash is preserved
255
+ data={"param": "value"},
256
+ method="POST",
257
+ params={"query": "test"}
258
+ )
259
+
260
+ assert result == {"key": "value"}
261
+
262
+ def test_format_validation_string_and_enum():
263
+ """Test that the format field accepts both string and enum values."""
264
+ # Test with enum value
265
+ params1 = CreateEnrichmentParameters(
266
+ description="Test description",
267
+ format=Format.text
268
+ )
269
+ # Since use_enum_values=True in ExaBaseModel, the enum is converted to its string value
270
+ assert params1.format == Format.text.value
271
+
272
+ # Test with string value
273
+ params2 = CreateEnrichmentParameters(
274
+ description="Test description",
275
+ format="text"
276
+ )
277
+ assert params2.format == "text"
278
+
279
+ # Both should serialize to the same value
280
+ assert params1.model_dump()["format"] == params2.model_dump()["format"]
281
+
282
+ # Test with invalid string value
283
+ with pytest.raises(ValueError):
284
+ CreateEnrichmentParameters(
285
+ description="Test description",
286
+ format="invalid_format"
287
+ )
288
+
289
+ def test_dict_and_model_parameter_support(websets_client, parent_mock):
290
+ """Test that client methods accept both dictionaries and model instances."""
291
+ from exa_py.websets.types import CreateWebsetParameters, Format
292
+
293
+ # Set up mock response
294
+ mock_response = {
295
+ "id": "ws_123",
296
+ "object": "webset",
297
+ "status": "idle",
298
+ "externalId": None,
299
+ "createdAt": "2023-01-01T00:00:00Z",
300
+ "updatedAt": "2023-01-01T00:00:00Z",
301
+ "searches": [],
302
+ "enrichments": []
303
+ }
304
+ parent_mock.request.return_value = mock_response
305
+
306
+ # Test with a model instance
307
+ model_params = CreateWebsetParameters(
308
+ search={
309
+ "query": "Test query",
310
+ "count": 10
311
+ },
312
+ enrichments=[{
313
+ "description": "Test enrichment",
314
+ "format": Format.text
315
+ }]
316
+ )
317
+ model_result = websets_client.create(params=model_params)
318
+
319
+ # Test with an equivalent dictionary
320
+ dict_params = {
321
+ "search": {
322
+ "query": "Test query",
323
+ "count": 10
324
+ },
325
+ "enrichments": [{
326
+ "description": "Test enrichment",
327
+ "format": "text"
328
+ }]
329
+ }
330
+ dict_result = websets_client.create(params=dict_params)
331
+
332
+ # Verify both calls produce the same result
333
+ assert model_result.id == dict_result.id
334
+ assert model_result.status == dict_result.status
335
+
336
+ # Verify both calls were made (we don't need to verify exact equality of serialized data)
337
+ assert len(parent_mock.request.call_args_list) == 2
338
+
339
+ # Both serialization approaches should have the same functionality
340
+ # The differences (Enum vs string, float vs int) are still valid when sent to the API
341
+ model_call_data = parent_mock.request.call_args_list[0][1]['data']
342
+ dict_call_data = parent_mock.request.call_args_list[1][1]['data']
343
+
344
+ # Check that fields are functionally equivalent
345
+ assert model_call_data['search']['query'] == dict_call_data['search']['query']
346
+ assert float(model_call_data['search']['count']) == float(dict_call_data['search']['count'])
347
+ assert model_call_data['enrichments'][0]['description'] == dict_call_data['enrichments'][0]['description']
348
+
349
+ # For format, we should get either the enum's value or the string directly
350
+ format1 = model_call_data['enrichments'][0]['format']
351
+ format2 = dict_call_data['enrichments'][0]['format']
352
+
353
+ # If format1 is an enum, get its value
354
+ format1_value = format1.value if hasattr(format1, 'value') else format1
355
+ # If format2 is an enum, get its value
356
+ format2_value = format2.value if hasattr(format2, 'value') else format2
357
+
358
+ assert format1_value == format2_value
359
+
360
+ def test_webhook_attempts_list(websets_client, parent_mock):
361
+ """Test that the WebhookAttemptsClient.list method works correctly."""
362
+ # Mock response for webhook attempts
363
+ mock_response = {
364
+ "data": [{
365
+ "id": "attempt_123",
366
+ "object": "webhook_attempt",
367
+ "eventId": "event_123",
368
+ "eventType": "webset.created",
369
+ "webhookId": "webhook_123",
370
+ "url": "https://example.com/webhook",
371
+ "successful": True,
372
+ "responseHeaders": {"content-type": "application/json"},
373
+ "responseBody": '{"status": "ok"}',
374
+ "responseStatusCode": 200,
375
+ "attempt": 1,
376
+ "attemptedAt": "2023-01-01T00:00:00Z"
377
+ }],
378
+ "hasMore": False,
379
+ "nextCursor": None
380
+ }
381
+
382
+ parent_mock.request.return_value = mock_response
383
+
384
+ # Test without optional parameters
385
+ result = websets_client.webhooks.attempts.list(webhook_id="webhook_123")
386
+
387
+ parent_mock.request.assert_called_with(
388
+ "/websets//v0/webhooks/webhook_123/attempts",
389
+ params={},
390
+ method="GET",
391
+ data=None
392
+ )
393
+
394
+ assert len(result.data) == 1
395
+ assert result.data[0].id == "attempt_123"
396
+ assert result.data[0].event_type == "webset.created"
397
+ assert result.data[0].successful is True
398
+
399
+ # Reset mock and test with all optional parameters
400
+ parent_mock.request.reset_mock()
401
+ parent_mock.request.return_value = mock_response
402
+
403
+ result = websets_client.webhooks.attempts.list(
404
+ webhook_id="webhook_123",
405
+ cursor="cursor_value",
406
+ limit=10,
407
+ event_type="webset.created"
408
+ )
409
+
410
+ parent_mock.request.assert_called_with(
411
+ "/websets//v0/webhooks/webhook_123/attempts",
412
+ params={"cursor": "cursor_value", "limit": 10, "eventType": "webset.created"},
413
+ method="GET",
414
+ data=None
415
+ )
@@ -1,42 +0,0 @@
1
- {% for decorator in decorators -%}
2
- {{ decorator }}
3
- {% endfor -%}
4
- class {{ class_name }}({{ base_class }}):{% if comment is defined %} # {{ comment }}{% endif %}
5
- {%- if description %}
6
- """
7
- {{ description | indent(4) }}
8
- """
9
- {%- endif %}
10
- {%- if not fields and not description %}
11
- pass
12
- {%- endif %}
13
- {%- if config %}
14
- {%- filter indent(4) %}
15
- {%- endfilter %}
16
- {%- endif %}
17
- {%- for field in fields -%}
18
- {%- if field.name == "type" and field.field %}
19
- type: Literal['{{ field.default }}']
20
- {%- elif field.name == "object" and field.field %}
21
- object: Literal['{{ field.default }}']
22
- {%- elif not field.annotated and field.field %}
23
- {{ field.name }}: {{ field.type_hint }} = {{ field.field }}
24
- {%- else %}
25
- {%- if field.annotated %}
26
- {{ field.name }}: {{ field.annotated }}
27
- {%- else %}
28
- {{ field.name }}: {{ field.type_hint }}
29
- {%- endif %}
30
- {%- if not (field.required or (field.represented_default == 'None' and field.strip_default_none)) or field.data_type.is_optional
31
- %} = {{ field.represented_default }}
32
- {%- endif -%}
33
- {%- endif %}
34
- {%- if field.docstring %}
35
- """
36
- {{ field.docstring | indent(4) }}
37
- """
38
- {%- endif %}
39
- {%- for method in methods -%}
40
- {{ method }}
41
- {%- endfor -%}
42
- {%- endfor -%}
File without changes
File without changes
File without changes
File without changes