From 77fe25674b6e285b02ad67e4845db39bc4e2d6ec Mon Sep 17 00:00:00 2001 From: cjumel Date: Wed, 17 Sep 2025 16:03:18 +0200 Subject: [PATCH 1/2] fix!: use API parameters order and defaults This commit performs a quite significant update of the SDK: - make sure the LinkupClient has the same parameter ordering as in the API documentation (might be breaking for users using only positional arguments) - delegate the choice of the default parameters to the API instead of re-implementating them (should not be breaking anything now, might be breaking in the future if the API defaults change themselves) - update the documentation --- README.md | 14 +- ..._results.py => 1_search_results_search.py} | 8 +- examples/2_sourced_answer_search.py | 10 +- examples/3_structured_search.py | 12 +- examples/4_asynchronous_search.py | 6 +- examples/5_fetch.py | 9 +- src/linkup/client.py | 170 ++++++++++-------- src/linkup/types.py | 18 +- tests/unit/client_test.py | 53 +----- tests/unit/conftest.py | 3 +- 10 files changed, 158 insertions(+), 145 deletions(-) rename examples/{1_direct_search_results.py => 1_search_results_search.py} (56%) diff --git a/README.md b/README.md index 96669cb..48bf26e 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ pip install linkup-sdk SDK. ```bash - export LINKUP_API_KEY='YOUR_LINKUP_API_KEY' + export LINKUP_API_KEY= ``` Option 2: Set the `LINKUP_API_KEY` environment variable directly within Python, using for @@ -49,7 +49,7 @@ pip install linkup-sdk import os from linkup import LinkupClient - os.environ["LINKUP_API_KEY"] = "YOUR_LINKUP_API_KEY" + os.environ["LINKUP_API_KEY"] = "" # or dotenv.load_dotenv() client = LinkupClient() ... @@ -60,7 +60,7 @@ pip install linkup-sdk ```python from linkup import LinkupClient - client = LinkupClient(api_key="YOUR_LINKUP_API_KEY") + client = LinkupClient(api_key="") ... ``` @@ -111,6 +111,10 @@ print(search_response.model_dump()) # } ``` +Check the code or the +[official documentation](https://docs.linkup.so/pages/documentation/api-reference/endpoint/post-search) +for the detailed list of available parameters. + #### 🪝 Fetch The `fetch` function can be used to retrieve the content of a given web page in a cleaned up @@ -136,6 +140,10 @@ print(fetch_response.model_dump()) # } ``` +Check the code or the +[official documentation](https://docs.linkup.so/pages/documentation/api-reference/endpoint/post-fetch) +for the detailed list of available parameters. + #### 📚 More Examples See the `examples/` directory for more examples and documentation, for instance on how to use Linkup diff --git a/examples/1_direct_search_results.py b/examples/1_search_results_search.py similarity index 56% rename from examples/1_direct_search_results.py rename to examples/1_search_results_search.py index a7f9041..bf912dc 100644 --- a/examples/1_direct_search_results.py +++ b/examples/1_search_results_search.py @@ -1,6 +1,10 @@ -""" +"""Example of search results search. + The Linkup search can output raw search results which can then be re-used in different use-cases, -for instance in a RAG system, with the output_type parameter set to "searchResults". +for instance in a RAG system, with the `output_type` parameter set to `searchResults`. + +To use this script, copy the `.env.example` file at the root of the repository inside a `.env`, and +fill the missing values, or pass a Linkup API key to the `LinkupClient` initialization. """ from dotenv import load_dotenv diff --git a/examples/2_sourced_answer_search.py b/examples/2_sourced_answer_search.py index 5957d78..084893c 100644 --- a/examples/2_sourced_answer_search.py +++ b/examples/2_sourced_answer_search.py @@ -1,7 +1,11 @@ -""" -The Linkup search can also be used to perform direct Question Answering, with output_type set to -"sourcedAnswer". In this case, the API will output an answer to the query in natural language, +"""Example of sourced answer search. + +The Linkup search can also be used to perform direct Question Answering, with `output_type` set to +`sourcedAnswer`. In this case, the API will output an answer to the query in natural language, along with the sources supporting it. + +To use this script, copy the `.env.example` file at the root of the repository inside a `.env`, and +fill the missing values, or pass a Linkup API key to the `LinkupClient` initialization. """ from dotenv import load_dotenv diff --git a/examples/3_structured_search.py b/examples/3_structured_search.py index 6d03ffc..a7606a3 100644 --- a/examples/3_structured_search.py +++ b/examples/3_structured_search.py @@ -1,7 +1,11 @@ -""" -With output_type set to "structured", the Linkup search can be used to require any arbitrary data -structure, based on a JSON schema or a pydantic.BaseModel. This can be used with a well defined and -documented schema to steer the Linkup search in any direction. +"""Example of a structured search. + +With `output_type` set to `structured`, the Linkup search can be used to require any arbitrary data +structure, based on a JSON schema or a `pydantic.BaseModel`. This can be used with a well defined +and documented schema to steer the Linkup search in any direction. + +To use this script, copy the `.env.example` file at the root of the repository inside a `.env`, and +fill the missing values, or pass a Linkup API key to the `LinkupClient` initialization. """ from dotenv import load_dotenv diff --git a/examples/4_asynchronous_search.py b/examples/4_asynchronous_search.py index 529b905..32b909e 100644 --- a/examples/4_asynchronous_search.py +++ b/examples/4_asynchronous_search.py @@ -1,7 +1,11 @@ -""" +"""Example of asynchronous search. + All Linkup entrypoints come with an asynchronous version. This snippet demonstrates how to run multiple asynchronous searches concurrently, which decreases by a lot the total computation duration. + +To use this script, copy the `.env.example` file at the root of the repository inside a `.env`, and +fill the missing values, or pass a Linkup API key to the `LinkupClient` initialization. """ import asyncio diff --git a/examples/5_fetch.py b/examples/5_fetch.py index 9c1b4d6..52fbecf 100644 --- a/examples/5_fetch.py +++ b/examples/5_fetch.py @@ -1,5 +1,9 @@ -""" -The Linkup fetch can output the raw content of a web page. +"""Example of web page fetch. + +The Linkup fetch can output the content of a web page as a cleaned up markdown. + +To use this script, copy the `.env.example` file at the root of the repository inside a `.env`, and +fill the missing values, or pass a Linkup API key to the `LinkupClient` initialization. """ from dotenv import load_dotenv @@ -12,6 +16,5 @@ response = client.fetch( url="https://docs.linkup.so", - render_js=False, ) print(response) diff --git a/src/linkup/client.py b/src/linkup/client.py index 90c6823..462a61e 100644 --- a/src/linkup/client.py +++ b/src/linkup/client.py @@ -20,16 +20,15 @@ class LinkupClient: - """The Linkup Client class. - - The LinkupClient class provides functions and other tools to interact with the Linkup API in - Python, making possible to perform search queries based on the Linkup API sources, that is the - web and the Linkup Premium Partner sources, using natural language. + """The Linkup Client class, providing functions to call the Linkup API endpoints using Python. Args: api_key: The API key for the Linkup API. If None, the API key will be read from the environment variable `LINKUP_API_KEY`. - base_url: The base URL for the Linkup API. In general, there's no need to change this. + base_url: The base URL for the Linkup API, for development purposes. + + Raises: + ValueError: If the API key is not provided and not found in the environment variable. """ __version__ = __version__ @@ -53,14 +52,17 @@ def search( depth: Literal["standard", "deep"], output_type: Literal["searchResults", "sourcedAnswer", "structured"], structured_output_schema: Union[type[BaseModel], str, None] = None, - include_images: bool = False, - exclude_domains: Union[list[str], None] = None, - include_domains: Union[list[str], None] = None, - from_date: Union[date, None] = None, - to_date: Union[date, None] = None, + include_images: Optional[bool] = None, + from_date: Optional[date] = None, + to_date: Optional[date] = None, + exclude_domains: Optional[list[str]] = None, + include_domains: Optional[list[str]] = None, ) -> Any: - """ - Search for a query in the Linkup API. + """Perform a web search using the Linkup API `search` endpoint. + + All optional parameters will default to the Linkup API defaults when not provided. The + Linkup API defaults are available in the + [official documentation](https://docs.linkup.so/pages/documentation/api-reference/endpoint/post-search). Args: query: The search query. @@ -74,12 +76,12 @@ def search( output. Supported formats are a pydantic.BaseModel or a string representing a valid object JSON schema. include_images: Indicate whether images should be included during the search. - exclude_domains: If you want to exclude specific domains from your search. - include_domains: If you want the search to only return results from certain domains. from_date: The date from which the search results should be considered. If None, the search results will not be filtered by date. to_date: The date until which the search results should be considered. If None, the search results will not be filtered by date. + exclude_domains: If you want to exclude specific domains from your search. + include_domains: If you want the search to only return results from certain domains. Returns: The Linkup API search result. If output_type is "searchResults", the result will be a @@ -97,16 +99,16 @@ def search( LinkupInsufficientCreditError: If you have run out of credit. LinkupNoResultError: If the search query did not yield any result. """ - params: dict[str, Union[str, bool, list[str], None]] = self._get_search_params( + params: dict[str, Union[str, bool, list[str]]] = self._get_search_params( query=query, depth=depth, output_type=output_type, structured_output_schema=structured_output_schema, include_images=include_images, - exclude_domains=exclude_domains, - include_domains=include_domains, from_date=from_date, to_date=to_date, + exclude_domains=exclude_domains, + include_domains=include_domains, ) response: httpx.Response = self._request( @@ -130,14 +132,17 @@ async def async_search( depth: Literal["standard", "deep"], output_type: Literal["searchResults", "sourcedAnswer", "structured"], structured_output_schema: Union[type[BaseModel], str, None] = None, - include_images: bool = False, - exclude_domains: Union[list[str], None] = None, - include_domains: Union[list[str], None] = None, - from_date: Union[date, None] = None, - to_date: Union[date, None] = None, + include_images: Optional[bool] = None, + from_date: Optional[date] = None, + to_date: Optional[date] = None, + exclude_domains: Optional[list[str]] = None, + include_domains: Optional[list[str]] = None, ) -> Any: - """ - Asynchronously search for a query in the Linkup API. + """Asynchronously perform a web search using the Linkup API `search` endpoint. + + All optional parameters will default to the Linkup API defaults when not provided. The + Linkup API defaults are available in the + [official documentation](https://docs.linkup.so/pages/documentation/api-reference/endpoint/post-search). Args: query: The search query. @@ -151,12 +156,12 @@ async def async_search( output. Supported formats are a pydantic.BaseModel or a string representing a valid object JSON schema. include_images: Indicate whether images should be included during the search. - exclude_domains: If you want to exclude specific domains from your search. - include_domains: If you want the search to only return results from certain domains. from_date: The date from which the search results should be considered. If None, the search results will not be filtered by date. to_date: The date until which the search results should be considered. If None, the search results will not be filtered by date. + exclude_domains: If you want to exclude specific domains from your search. + include_domains: If you want the search to only return results from certain domains. Returns: The Linkup API search result. If output_type is "searchResults", the result will be a @@ -170,19 +175,20 @@ async def async_search( pydantic.BaseModel when output_type is "structured". LinkupInvalidRequestError: If structured_output_schema doesn't represent a valid object JSON schema when output_type is "structured". - LinkupAuthenticationError: If the Linkup API key is invalid, or there is no more credit - available. + LinkupAuthenticationError: If the Linkup API key is invalid. + LinkupInsufficientCreditError: If you have run out of credit. + LinkupNoResultError: If the search query did not yield any result. """ - params: dict[str, Union[str, bool, list[str], None]] = self._get_search_params( + params: dict[str, Union[str, bool, list[str]]] = self._get_search_params( query=query, depth=depth, output_type=output_type, structured_output_schema=structured_output_schema, include_images=include_images, - exclude_domains=exclude_domains, - include_domains=include_domains, from_date=from_date, to_date=to_date, + exclude_domains=exclude_domains, + include_domains=include_domains, ) response: httpx.Response = await self._async_request( @@ -203,23 +209,31 @@ async def async_search( def fetch( self, url: str, - render_js: bool = False, - include_raw_html: bool = False, + include_raw_html: Optional[bool] = None, + render_js: Optional[bool] = None, ) -> LinkupFetchResponse: - """Fetch the content of a web page. + """Fetch the content of a web page using the Linkup API `fetch` endpoint. + + All optional parameters will default to the Linkup API defaults when not provided. The + Linkup API defaults are available in the + [official documentation](https://docs.linkup.so/pages/documentation/api-reference/endpoint/post-fetch). Args: url: The URL of the web page to fetch. - render_js: Whether the API should render the JavaScript of the webpage. include_raw_html: Whether to include the raw HTML of the webpage in the response. + render_js: Whether the API should render the JavaScript of the webpage. Returns: The response of the web page fetch, containing the web page content. + + Raises: + LinkupInvalidRequestError: If the provided URL is not valid. + LinkupFailedFetchError: If the provided URL is not found or can't be fetched. """ params: dict[str, Union[str, bool]] = self._get_fetch_params( url=url, - render_js=render_js, include_raw_html=include_raw_html, + render_js=render_js, ) response: httpx.Response = self._request( @@ -236,23 +250,31 @@ def fetch( async def async_fetch( self, url: str, - render_js: bool = False, - include_raw_html: bool = False, + include_raw_html: Optional[bool] = None, + render_js: Optional[bool] = None, ) -> LinkupFetchResponse: - """Asynchronously fetch the content of a web page. + """Asynchronously fetch the content of a web page using the Linkup API `fetch` endpoint. + + All optional parameters will default to the Linkup API defaults when not provided. The + Linkup API defaults are available in the + [official documentation](https://docs.linkup.so/pages/documentation/api-reference/endpoint/post-fetch). Args: url: The URL of the web page to fetch. - render_js: Whether the API should render the JavaScript of the webpage. include_raw_html: Whether to include the raw HTML of the webpage in the response. + render_js: Whether the API should render the JavaScript of the webpage. Returns: The response of the web page fetch, containing the web page content. + + Raises: + LinkupInvalidRequestError: If the provided URL is not valid. + LinkupFailedFetchError: If the provided URL is not found or can't be fetched. """ params: dict[str, Union[str, bool]] = self._get_fetch_params( url=url, - render_js=render_js, include_raw_html=include_raw_html, + render_js=render_js, ) response: httpx.Response = await self._async_request( @@ -383,47 +405,55 @@ def _get_search_params( depth: Literal["standard", "deep"], output_type: Literal["searchResults", "sourcedAnswer", "structured"], structured_output_schema: Union[type[BaseModel], str, None], - include_images: bool, - exclude_domains: Union[list[str], None], - include_domains: Union[list[str], None], - from_date: Union[date, None], - to_date: Union[date, None], - ) -> dict[str, Union[str, bool, list[str], None]]: - structured_output_schema_param: str = "" + include_images: Optional[bool], + from_date: Optional[date], + to_date: Optional[date], + exclude_domains: Optional[list[str]], + include_domains: Optional[list[str]], + ) -> dict[str, Union[str, bool, list[str]]]: + params: dict[str, Union[str, bool, list[str]]] = dict( + q=query, + depth=depth, + outputType=output_type, + ) + if structured_output_schema is not None: if isinstance(structured_output_schema, str): - structured_output_schema_param = structured_output_schema + params["structuredOutputSchema"] = structured_output_schema elif issubclass(structured_output_schema, BaseModel): json_schema: dict[str, Any] = structured_output_schema.model_json_schema() - structured_output_schema_param = json.dumps(json_schema) + params["structuredOutputSchema"] = json.dumps(json_schema) else: raise TypeError( f"Unexpected structured_output_schema type: '{type(structured_output_schema)}'" ) - - return dict( - q=query, - depth=depth, - outputType=output_type, - structuredOutputSchema=structured_output_schema_param, - includeImages=include_images, - excludeDomains=exclude_domains or [], - includeDomains=include_domains or [], - fromDate=from_date.isoformat() if from_date is not None else None, - toDate=to_date.isoformat() if to_date is not None else date.today().isoformat(), - ) + if include_images is not None: + params["includeImages"] = include_images + if from_date is not None: + params["fromDate"] = from_date.isoformat() + if to_date is not None: + params["toDate"] = to_date.isoformat() + if exclude_domains is not None: + params["excludeDomains"] = exclude_domains + if include_domains is not None: + params["includeDomains"] = include_domains + + return params def _get_fetch_params( self, url: str, - render_js: bool, - include_raw_html: bool = False, + include_raw_html: Optional[bool], + render_js: Optional[bool], ) -> dict[str, Union[str, bool]]: - return dict( - url=url, - renderJs=render_js, - includeRawHtml=include_raw_html, - ) + params: dict[str, Union[str, bool]] = { + "url": url, + } + if include_raw_html is not None: + params["includeRawHtml"] = include_raw_html + if render_js is not None: + params["renderJs"] = render_js + return params def _parse_search_response( self, diff --git a/src/linkup/types.py b/src/linkup/types.py index f5de4f5..af509aa 100644 --- a/src/linkup/types.py +++ b/src/linkup/types.py @@ -4,8 +4,7 @@ class LinkupSearchTextResult(BaseModel): - """ - A text result from a Linkup search. + """A text result from a Linkup search. Attributes: type: The type of the search result, in this case "text". @@ -21,8 +20,7 @@ class LinkupSearchTextResult(BaseModel): class LinkupSearchImageResult(BaseModel): - """ - An image result from a Linkup search. + """An image result from a Linkup search. Attributes: type: The type of the search result, in this case "image". @@ -36,8 +34,7 @@ class LinkupSearchImageResult(BaseModel): class LinkupSearchResults(BaseModel): - """ - The results of the Linkup search. + """The results of the Linkup search. Attributes: results: The results of the Linkup search. @@ -47,8 +44,7 @@ class LinkupSearchResults(BaseModel): class LinkupSource(BaseModel): - """ - A source supporting a Linkup answer. + """A source supporting a Linkup answer. Attributes: name: The name of the source. @@ -62,8 +58,7 @@ class LinkupSource(BaseModel): class LinkupSourcedAnswer(BaseModel): - """ - A Linkup answer, with the sources supporting it. + """A Linkup answer, with the sources supporting it. Attributes: answer: The answer text. @@ -75,8 +70,7 @@ class LinkupSourcedAnswer(BaseModel): class LinkupFetchResponse(BaseModel): - """ - The response from a Linkup web page fetch. + """The response from a Linkup web page fetch. Attributes: markdown: The cleaned up markdown content. diff --git a/tests/unit/client_test.py b/tests/unit/client_test.py index 2b32f7f..354c747 100644 --- a/tests/unit/client_test.py +++ b/tests/unit/client_test.py @@ -35,17 +35,7 @@ class Company(BaseModel): test_search_parameters = [ ( {"query": "query", "depth": "standard", "output_type": "searchResults"}, - { - "q": "query", - "depth": "standard", - "outputType": "searchResults", - "structuredOutputSchema": "", - "includeImages": False, - "excludeDomains": [], - "includeDomains": [], - "fromDate": None, - "toDate": "2000-01-01", - }, + {"q": "query", "depth": "standard", "outputType": "searchResults"}, b""" { "results": [ @@ -83,36 +73,25 @@ class Company(BaseModel): "include_images": True, "from_date": date(2023, 1, 1), "to_date": date(2023, 12, 31), - "include_domains": ["example.com", "example.org"], "exclude_domains": ["excluded.com"], + "include_domains": ["example.com", "example.org"], }, { "q": "A long query.", "depth": "deep", "outputType": "searchResults", - "structuredOutputSchema": "", "includeImages": True, - "excludeDomains": ["excluded.com"], - "includeDomains": ["example.com", "example.org"], "fromDate": "2023-01-01", "toDate": "2023-12-31", + "excludeDomains": ["excluded.com"], + "includeDomains": ["example.com", "example.org"], }, b'{"results": []}', LinkupSearchResults(results=[]), ), ( {"query": "query", "depth": "standard", "output_type": "sourcedAnswer"}, - { - "q": "query", - "depth": "standard", - "outputType": "sourcedAnswer", - "structuredOutputSchema": "", - "includeImages": False, - "excludeDomains": [], - "includeDomains": [], - "fromDate": None, - "toDate": "2000-01-01", - }, + {"q": "query", "depth": "standard", "outputType": "sourcedAnswer"}, b""" { "answer": "foo bar baz", @@ -156,11 +135,6 @@ class Company(BaseModel): "depth": "standard", "outputType": "structured", "structuredOutputSchema": json.dumps(Company.model_json_schema()), - "includeImages": False, - "excludeDomains": [], - "includeDomains": [], - "fromDate": None, - "toDate": "2000-01-01", }, b""" { @@ -189,11 +163,6 @@ class Company(BaseModel): "depth": "standard", "outputType": "structured", "structuredOutputSchema": json.dumps(Company.model_json_schema()), - "includeImages": False, - "excludeDomains": [], - "includeDomains": [], - "fromDate": None, - "toDate": "2000-01-01", }, b""" { @@ -443,19 +412,13 @@ async def test_async_search_error( test_fetch_parameters = [ ( {"url": "https://example.com"}, - {"url": "https://example.com", "renderJs": False, "includeRawHtml": False}, + {"url": "https://example.com"}, b'{"markdown": "Some web page content"}', LinkupFetchResponse(markdown="Some web page content", raw_html=None), ), ( - {"url": "https://example.com", "render_js": True}, - {"url": "https://example.com", "renderJs": True, "includeRawHtml": False}, - b'{"markdown": "#Some web page content"}', - LinkupFetchResponse(markdown="#Some web page content", raw_html=None), - ), - ( - {"url": "https://example.com", "include_raw_html": True}, - {"url": "https://example.com", "renderJs": False, "includeRawHtml": True}, + {"url": "https://example.com", "include_raw_html": True, "render_js": True}, + {"url": "https://example.com", "includeRawHtml": True, "renderJs": True}, b'{"markdown": "#Some web page content", "rawHtml": "..."}', LinkupFetchResponse(markdown="#Some web page content", raw_html="..."), ), diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 2494ab0..09be925 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -8,6 +8,5 @@ @pytest.fixture(scope="session") def client() -> LinkupClient: if os.getenv("LINKUP_API_KEY") is None: - os.environ["LINKUP_API_KEY"] = "fake-api" - + os.environ["LINKUP_API_KEY"] = "" return LinkupClient() From e105205c6bf8faab3ca45faff203bf854dbfa12e Mon Sep 17 00:00:00 2001 From: cjumel Date: Wed, 17 Sep 2025 16:57:45 +0200 Subject: [PATCH 2/2] fix!: specify minimal version of main requirements This minimal versions should not be very constraining, since they are quite old, but in theory this is still a breaking change. --- pyproject.toml | 2 +- uv.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0aa9868..fb9aea0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", ] -dependencies = ["httpx", "pydantic"] +dependencies = ["httpx>=0.23.0", "pydantic>=2.0.0"] [project.optional-dependencies] build = ["uv>=0.8.0,<0.9.0"] # For python-semantic-release build command, used in GitHub actions diff --git a/uv.lock b/uv.lock index 4164d7a..6cf0eda 100644 --- a/uv.lock +++ b/uv.lock @@ -267,8 +267,8 @@ dev = [ [package.metadata] requires-dist = [ - { name = "httpx" }, - { name = "pydantic" }, + { name = "httpx", specifier = ">=0.23.0" }, + { name = "pydantic", specifier = ">=2.0.0" }, { name = "uv", marker = "extra == 'build'", specifier = ">=0.8.0,<0.9.0" }, ] provides-extras = ["build"]