From f6a2788996e2ac967a83e4dff64db285c86e3953 Mon Sep 17 00:00:00 2001 From: cjumel Date: Wed, 20 May 2026 16:56:52 +0200 Subject: [PATCH 1/3] chore: replace mdformat by rumdl This follows our internal convention. Rumdl is faster and has more features (like dealing well with front-matters) than mdformat. --- .mdformat.toml | 2 -- .pre-commit-config.yaml | 6 +++--- .rumdl.toml | 14 ++++++++++++++ pyproject.toml | 7 ------- 4 files changed, 17 insertions(+), 12 deletions(-) delete mode 100644 .mdformat.toml create mode 100644 .rumdl.toml diff --git a/.mdformat.toml b/.mdformat.toml deleted file mode 100644 index dbe02d6..0000000 --- a/.mdformat.toml +++ /dev/null @@ -1,2 +0,0 @@ -number = true -wrap = 100 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a9c4758..afd282e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,10 +17,10 @@ repos: args: [--fix] - id: ruff-format - - repo: https://github.com/hukkin/mdformat - rev: 0.7.22 + - repo: https://github.com/rvben/rumdl-pre-commit + rev: v0.1.46 hooks: - - id: mdformat + - id: rumdl-fmt exclude: CHANGELOG.md - repo: https://github.com/ComPWA/taplo-pre-commit diff --git a/.rumdl.toml b/.rumdl.toml new file mode 100644 index 0000000..6f4772b --- /dev/null +++ b/.rumdl.toml @@ -0,0 +1,14 @@ +[global] +line-length = 100 + +[MD013] +reflow = true + +[MD032] +allow-lazy-continuation = false + +[MD033] +allowed-elements = ["details", "summary"] + +[per-file-ignores] +".github/pull_request_template.md" = ["MD041"] diff --git a/pyproject.toml b/pyproject.toml index bcb190d..648d017 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,6 @@ Homepage = "https://github.com/LinkupPlatform/linkup-python-sdk" Source = "https://github.com/LinkupPlatform/linkup-python-sdk" Tracker = "https://github.com/LinkupPlatform/linkup-python-sdk/issues" - [dependency-groups] dev = [ "prek>=0.3.5", @@ -53,13 +52,11 @@ typeCheckingMode = "strict" [tool.pytest.ini_options] asyncio_default_fixture_loop_scope = "function" - [tool.coverage.report] exclude_also = ["raise TypeError", "raise ValueError"] show_missing = true skip_covered = true - [tool.ruff] line-length = 100 target-version = "py310" @@ -121,11 +118,9 @@ ignore = [ [tool.ruff.lint.flake8-type-checking] runtime-evaluated-base-classes = ["pydantic.BaseModel"] - [tool.hatch.build.targets.wheel] packages = ["src/linkup"] # Because project and source code directory names differ - [tool.semantic_release] allow_zero_version = true build_command = """ @@ -141,7 +136,6 @@ version_toml = ["pyproject.toml:project.version"] [tool.semantic_release.commit_parser_options] parse_squash_commits = false - [tool.uv] exclude-newer = "2 weeks" # Reduce risks of supply chain attacks required-version = ">=0.10.0,<0.11.0" @@ -149,4 +143,3 @@ required-version = ">=0.10.0,<0.11.0" [build-system] build-backend = "hatchling.build" requires = ["hatchling"] - From b2449548fba707da9b672a1d1969fc4a3a077c3b Mon Sep 17 00:00:00 2001 From: cjumel Date: Wed, 20 May 2026 17:09:35 +0200 Subject: [PATCH 2/3] chore: remove examples We decided to move most of the documentation in our API documentation, so let's only keep the examples mentioned in the README. --- AGENTS.md | 2 +- README.md | 5 --- examples/1_search_results_search.py | 23 -------------- examples/2_sourced_answer_search.py | 25 --------------- examples/3_structured_search.py | 37 ---------------------- examples/4_asynchronous_search.py | 49 ----------------------------- examples/5_fetch.py | 20 ------------ pyproject.toml | 1 - 8 files changed, 1 insertion(+), 161 deletions(-) delete mode 100644 examples/1_search_results_search.py delete mode 100644 examples/2_sourced_answer_search.py delete mode 100644 examples/3_structured_search.py delete mode 100644 examples/4_asynchronous_search.py delete mode 100644 examples/5_fetch.py diff --git a/AGENTS.md b/AGENTS.md index b70f9a8..83ab069 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ When adding or changing a public API capability, update the relevant pieces toge - request/response typing and models, - sync and async behavior when applicable, - tests, -- README/examples if the user-facing API changed. +- README if the user-facing API changed. ## Validation diff --git a/README.md b/README.md index 4c15f1b..94587ee 100644 --- a/README.md +++ b/README.md @@ -292,8 +292,3 @@ result = client.search( ) print(result.answer) ``` - -#### 📚 More Examples - -See the `examples/` directory for more examples and documentation, for instance on how to use Linkup -entrypoints using asynchronous functions to call the Linkup API several times concurrenly. diff --git a/examples/1_search_results_search.py b/examples/1_search_results_search.py deleted file mode 100644 index de5b270..0000000 --- a/examples/1_search_results_search.py +++ /dev/null @@ -1,23 +0,0 @@ -"""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`. - -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 rich -from dotenv import load_dotenv - -from linkup import LinkupClient - -load_dotenv() -client = LinkupClient() - -response = client.search( - query="What are the 3 major events in the life of Abraham Lincoln?", - depth="standard", # or "deep" - output_type="searchResults", -) -rich.print(response) diff --git a/examples/2_sourced_answer_search.py b/examples/2_sourced_answer_search.py deleted file mode 100644 index e70dbdf..0000000 --- a/examples/2_sourced_answer_search.py +++ /dev/null @@ -1,25 +0,0 @@ -"""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. -""" - -import rich -from dotenv import load_dotenv - -from linkup import LinkupClient - -load_dotenv() -client = LinkupClient() - -response = client.search( - query="What are the 3 major events in the life of Abraham Lincoln ?", - depth="standard", # or "deep" - output_type="sourcedAnswer", - include_inline_citations=False, -) -rich.print(response) diff --git a/examples/3_structured_search.py b/examples/3_structured_search.py deleted file mode 100644 index cc69c93..0000000 --- a/examples/3_structured_search.py +++ /dev/null @@ -1,37 +0,0 @@ -"""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. -""" - -import rich -from dotenv import load_dotenv -from pydantic import BaseModel, Field - -from linkup import LinkupClient - - -class Event(BaseModel): - date: str = Field(description="The date of the event") - description: str = Field(description="The description of the event") - - -class Events(BaseModel): - events: list[Event] = Field(description="The list of events") - - -load_dotenv() -client = LinkupClient() - -response = client.search( - query="What are the 3 major events in the life of Abraham Lincoln?", - depth="standard", # or "deep" - output_type="structured", - structured_output_schema=Events, # or json.dumps(Events.model_json_schema()) - include_sources=False, -) -rich.print(response) diff --git a/examples/4_asynchronous_search.py b/examples/4_asynchronous_search.py deleted file mode 100644 index 88412b0..0000000 --- a/examples/4_asynchronous_search.py +++ /dev/null @@ -1,49 +0,0 @@ -"""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 -import time - -import rich -from dotenv import load_dotenv - -from linkup import LinkupClient - -load_dotenv() -client = LinkupClient() - -queries: list[str] = [ - "What are the 3 major events in the life of Abraham Lincoln?", - "What are the 3 major events in the life of George Washington?", -] - -t0: float = time.time() - - -async def search(idx: int, query: str) -> None: - """Run an asynchronous search and display its results and the duration from the beginning.""" - response = await client.async_search( - query=query, - depth="standard", # or "deep" - output_type="searchResults", # or "sourcedAnswer" or "structured" - ) - print(f"{idx + 1}: {time.time() - t0:.3f}s") - rich.print(response) - print("-" * 100) - - -async def main() -> None: - """Run multiple asynchronous searches concurrently.""" - coroutines = [search(idx=idx, query=query) for idx, query in enumerate(queries)] - await asyncio.gather(*coroutines) - print(f"Total time: {time.time() - t0:.3f}s") - - -asyncio.run(main()) diff --git a/examples/5_fetch.py b/examples/5_fetch.py deleted file mode 100644 index e35fba1..0000000 --- a/examples/5_fetch.py +++ /dev/null @@ -1,20 +0,0 @@ -"""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. -""" - -import rich -from dotenv import load_dotenv - -from linkup import LinkupClient - -load_dotenv() -client = LinkupClient() - -response = client.fetch( - url="https://docs.linkup.so", -) -rich.print(response) diff --git a/pyproject.toml b/pyproject.toml index 648d017..4737b12 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,7 +103,6 @@ select = [ ] [tool.ruff.lint.extend-per-file-ignores] -"examples/*.py" = ["D"] "src/linkup/__init__.py" = ["D104"] "tests/**/*test.py" = ["D", "S101"] From 8d75175d6b4c06a0823baa9cf5776b33c0868899 Mon Sep 17 00:00:00 2001 From: cjumel Date: Wed, 20 May 2026 17:37:10 +0200 Subject: [PATCH 3/3] fix: simplify linkup import with aliases This will make possible to import linkup with `import linkup` and use it with `linkup.Client()` which is more concise and follows the google style guide recommendation for imports, which is to only import modules. Former import style is left and will be kept in the foreseeable future. --- README.md | 48 +++++---- src/linkup/__init__.py | 66 +++++++++++++ tests/unit/client_test.py | 201 ++++++++++++++++---------------------- tests/unit/conftest.py | 10 +- tests/unit/init_test.py | 13 +++ 5 files changed, 192 insertions(+), 146 deletions(-) create mode 100644 tests/unit/init_test.py diff --git a/README.md b/README.md index 94587ee..9f8a131 100644 --- a/README.md +++ b/README.md @@ -59,20 +59,20 @@ pip install linkup-sdk[x402] ```python import os - from linkup import LinkupClient + import linkup os.environ["LINKUP_API_KEY"] = "" # or dotenv.load_dotenv() - client = LinkupClient() + client = linkup.Client() ... ``` Option 3: Pass the Linkup API key to the Linkup Client when creating it. ```python - from linkup import LinkupClient + import linkup - client = LinkupClient(api_key="") + client = linkup.Client(api_key="") ... ``` @@ -99,16 +99,16 @@ The `search` function also supports three output types: ```python from typing import Any -from linkup import LinkupClient, LinkupSourcedAnswer +import linkup -client = LinkupClient() # API key can be read from the environment variable or passed as an argument +client = linkup.Client() # API key can be read from the environment variable or passed as an argument search_response: Any = client.search( query="What are the 3 major events in the life of Abraham Lincoln?", depth="deep", # "standard" or "deep" output_type="sourcedAnswer", # "searchResults" or "sourcedAnswer" or "structured" structured_output_schema=None, # must be filled if output_type is "structured" ) -assert isinstance(search_response, LinkupSourcedAnswer) +assert isinstance(search_response, linkup.SourcedAnswer) print(search_response.model_dump()) ``` @@ -142,10 +142,10 @@ You can use the `render_js` flag to execute the JavaScript code of the page befo content, and ask to `include_raw_html` to the response if you feel like it. ```python -from linkup import LinkupClient, LinkupFetchResponse +import linkup -client = LinkupClient() # API key can be read from the environment variable or passed as an argument -fetch_response: LinkupFetchResponse = client.fetch( +client = linkup.Client() # API key can be read from the environment variable or passed as an argument +fetch_response: linkup.FetchResponse = client.fetch( url="https://docs.linkup.so", render_js=False, include_raw_html=True, @@ -172,10 +172,10 @@ The `research` function creates an asynchronous research task. You can then use `list_research` to inspect it later. ```python -from linkup import LinkupClient, LinkupResearchTask +import linkup -client = LinkupClient() -research_task: LinkupResearchTask = client.research( +client = linkup.Client() +research_task: linkup.ResearchTask = client.research( query="What changed in the AI browser market this quarter?", output_type="sourcedAnswer", ) @@ -188,21 +188,17 @@ The `create_tasks` function lets you submit mixed `search`, `fetch`, and `resear batch, then inspect them through `get_task` or `list_tasks`. ```python -from linkup import ( - LinkupClient, - LinkupFetchTaskInput, - LinkupSearchTaskInput, -) +import linkup -client = LinkupClient() +client = linkup.Client() tasks = client.create_tasks( [ - LinkupSearchTaskInput( + linkup.SearchTaskInput( query="Linkup latest product updates", depth="deep", output_type="sourcedAnswer", ), - LinkupFetchTaskInput( + linkup.FetchTaskInput( url="https://docs.linkup.so", ), ] @@ -221,17 +217,17 @@ This makes possible to call the Linkup API several times concurrently for instan import asyncio from typing import Any -from linkup import LinkupClient, LinkupSourcedAnswer +import linkup async def main() -> None: - client = LinkupClient() # API key can be read from the environment variable or passed as an argument + client = linkup.Client() # API key can be read from the environment variable or passed as an argument search_response: Any = await client.async_search( query="What are the 3 major events in the life of Abraham Lincoln?", depth="deep", # "standard" or "deep" output_type="sourcedAnswer", # "searchResults" or "sourcedAnswer" or "structured" structured_output_schema=None, # must be filled if output_type is "structured" ) - assert isinstance(search_response, LinkupSourcedAnswer) + assert isinstance(search_response, linkup.SourcedAnswer) print(search_response.model_dump()) asyncio.run(main()) @@ -279,11 +275,11 @@ account = Account.from_mnemonic("") Then pass it to `create_x402_signer` and use the Linkup client: ```python -from linkup import LinkupClient +import linkup from linkup.x402 import create_x402_signer signer = create_x402_signer(account) -client = LinkupClient(x402_signer=signer) +client = linkup.Client(x402_signer=signer) result = client.search( query="What is x402?", diff --git a/src/linkup/__init__.py b/src/linkup/__init__.py index 0d1030f..88a279e 100644 --- a/src/linkup/__init__.py +++ b/src/linkup/__init__.py @@ -36,7 +36,52 @@ ) from ._version import __version__ +# Aliases to allow usage like `import linkup` and `client = linkup.Client(...)` +AuthenticationError = LinkupAuthenticationError +Client = LinkupClient +FailedFetchError = LinkupFailedFetchError +FetchImageExtraction = LinkupFetchImageExtraction +FetchResponse = LinkupFetchResponse +FetchResponseTooLargeError = LinkupFetchResponseTooLargeError +FetchTask = LinkupFetchTask +FetchTaskInput = LinkupFetchTaskInput +FetchUrlIsFileError = LinkupFetchUrlIsFileError +InsufficientCreditError = LinkupInsufficientCreditError +InvalidRequestError = LinkupInvalidRequestError +NoResultError = LinkupNoResultError +PaymentRequiredError = LinkupPaymentRequiredError +ResearchTask = LinkupResearchTask +ResearchTaskInput = LinkupResearchTaskInput +ResearchTasksPage = LinkupResearchTasksPage +SearchImageResult = LinkupSearchImageResult +SearchResults = LinkupSearchResults +SearchStructuredResponse = LinkupSearchStructuredResponse +SearchTask = LinkupSearchTask +SearchTaskInput = LinkupSearchTaskInput +SearchTextResult = LinkupSearchTextResult +Source = LinkupSource +SourcedAnswer = LinkupSourcedAnswer +Task = LinkupTask +TaskInput = LinkupTaskInput +TaskMetadata = LinkupTaskMetadata +TaskQuota = LinkupTaskQuota +TasksPage = LinkupTasksPage +TimeoutError = LinkupTimeoutError # noqa: A001 +TooManyRequestsError = LinkupTooManyRequestsError +UnknownError = LinkupUnknownError + __all__ = [ + "AuthenticationError", + "Client", + "FailedFetchError", + "FetchImageExtraction", + "FetchResponse", + "FetchResponseTooLargeError", + "FetchTask", + "FetchTaskInput", + "FetchUrlIsFileError", + "InsufficientCreditError", + "InvalidRequestError", "LinkupAuthenticationError", "LinkupClient", "LinkupFailedFetchError", @@ -69,5 +114,26 @@ "LinkupTimeoutError", "LinkupTooManyRequestsError", "LinkupUnknownError", + "NoResultError", + "PaymentRequiredError", + "ResearchTask", + "ResearchTaskInput", + "ResearchTasksPage", + "SearchImageResult", + "SearchResults", + "SearchStructuredResponse", + "SearchTask", + "SearchTaskInput", + "SearchTextResult", + "Source", + "SourcedAnswer", + "Task", + "TaskInput", + "TaskMetadata", + "TaskQuota", + "TasksPage", + "TimeoutError", + "TooManyRequestsError", + "UnknownError", "__version__", ] diff --git a/tests/unit/client_test.py b/tests/unit/client_test.py index eb50536..925edc2 100644 --- a/tests/unit/client_test.py +++ b/tests/unit/client_test.py @@ -9,36 +9,7 @@ from pydantic import BaseModel from pytest_mock import MockerFixture -from linkup import ( - LinkupAuthenticationError, - LinkupClient, - LinkupFailedFetchError, - LinkupFetchResponse, - LinkupFetchResponseTooLargeError, - LinkupFetchTask, - LinkupFetchTaskInput, - LinkupFetchUrlIsFileError, - LinkupInsufficientCreditError, - LinkupInvalidRequestError, - LinkupNoResultError, - LinkupPaymentRequiredError, - LinkupResearchTask, - LinkupResearchTaskInput, - LinkupResearchTasksPage, - LinkupSearchImageResult, - LinkupSearchResults, - LinkupSearchStructuredResponse, - LinkupSearchTask, - LinkupSearchTaskInput, - LinkupSearchTextResult, - LinkupSource, - LinkupSourcedAnswer, - LinkupTaskMetadata, - LinkupTasksPage, - LinkupTimeoutError, - LinkupTooManyRequestsError, - LinkupUnknownError, -) +import linkup class Company(BaseModel): @@ -66,16 +37,16 @@ class Company(BaseModel): ] } """, - LinkupSearchResults( + linkup.SearchResults( results=[ - LinkupSearchTextResult( + linkup.SearchTextResult( type="text", name="foo", url="https://foo.com", content="lorem ipsum dolor sit amet", favicon="https://foo.com/favicon.ico", ), - LinkupSearchImageResult( + linkup.SearchImageResult( type="image", name="bar", url="https://bar.com", @@ -111,7 +82,7 @@ class Company(BaseModel): "includeSources": True, }, b'{"results": []}', - LinkupSearchResults(results=[]), + linkup.SearchResults(results=[]), ), ( { @@ -122,7 +93,7 @@ class Company(BaseModel): }, {"q": "query with timeout", "depth": "standard", "outputType": "searchResults"}, b'{"results": []}', - LinkupSearchResults(results=[]), + linkup.SearchResults(results=[]), ), ( {"query": "query", "depth": "standard", "output_type": "sourcedAnswer"}, @@ -137,20 +108,20 @@ class Company(BaseModel): ] } """, - LinkupSourcedAnswer( + linkup.SourcedAnswer( answer="foo bar baz", sources=[ - LinkupSource( + linkup.Source( name="foo", url="https://foo.com", snippet="lorem ipsum dolor sit amet", ), - LinkupSource( + linkup.Source( name="bar", url="https://bar.com", snippet="consectetur adipiscing elit", ), - LinkupSource( + linkup.Source( name="baz", url="https://baz.com", snippet="", @@ -248,7 +219,7 @@ class Company(BaseModel): ] } """, - LinkupSearchStructuredResponse( + linkup.SearchStructuredResponse( data=Company( name="Linkup", founders_names=["Philippe Mizrahi", "Denis Charrier", "Boris Toledano"], @@ -256,13 +227,13 @@ class Company(BaseModel): website_url="https://www.linkup.so/", ), sources=[ - LinkupSearchTextResult( + linkup.SearchTextResult( type="text", name="foo", url="https://foo.com", content="lorem ipsum dolor sit amet", ), - LinkupSearchImageResult(type="image", name="bar", url="https://bar.com"), + linkup.SearchImageResult(type="image", name="bar", url="https://bar.com"), ], ), ), @@ -280,7 +251,7 @@ class Company(BaseModel): ) def test_search( mocker: MockerFixture, - client: LinkupClient, + client: linkup.Client, search_kwargs: dict[str, Any], expected_request_params: dict[str, Any], mock_request_response_content: bytes, @@ -318,7 +289,7 @@ def test_search( ) async def test_async_search( mocker: MockerFixture, - client: LinkupClient, + client: linkup.Client, search_kwargs: dict[str, Any], expected_request_params: dict[str, Any], mock_request_response_content: bytes, @@ -356,7 +327,7 @@ async def test_async_search( } } """, - LinkupPaymentRequiredError, + linkup.PaymentRequiredError, ), ( 403, @@ -369,7 +340,7 @@ async def test_async_search( } } """, - LinkupAuthenticationError, + linkup.AuthenticationError, ), ( 401, @@ -382,7 +353,7 @@ async def test_async_search( } } """, - LinkupAuthenticationError, + linkup.AuthenticationError, ), ( 429, @@ -395,7 +366,7 @@ async def test_async_search( } } """, - LinkupInsufficientCreditError, + linkup.InsufficientCreditError, ), ( 429, @@ -408,7 +379,7 @@ async def test_async_search( } } """, - LinkupTooManyRequestsError, + linkup.TooManyRequestsError, ), ( 429, @@ -421,7 +392,7 @@ async def test_async_search( } } """, - LinkupUnknownError, + linkup.UnknownError, ), ( 400, @@ -439,7 +410,7 @@ async def test_async_search( } } """, - LinkupInvalidRequestError, + linkup.InvalidRequestError, ), ( 400, @@ -452,7 +423,7 @@ async def test_async_search( } } """, - LinkupNoResultError, + linkup.NoResultError, ), ( 500, @@ -465,7 +436,7 @@ async def test_async_search( } } """, - LinkupUnknownError, + linkup.UnknownError, ), ] @@ -476,7 +447,7 @@ async def test_async_search( ) def test_search_error( mocker: MockerFixture, - client: LinkupClient, + client: linkup.Client, mock_request_response_status_code: int, mock_request_response_content: bytes, expected_exception: type[Exception], @@ -501,7 +472,7 @@ def test_search_error( ) async def test_async_search_error( mocker: MockerFixture, - client: LinkupClient, + client: linkup.Client, mock_request_response_status_code: int, mock_request_response_content: bytes, expected_exception: type[Exception], @@ -521,34 +492,34 @@ async def test_async_search_error( def test_search_timeout( mocker: MockerFixture, - client: LinkupClient, + client: linkup.Client, ) -> None: mocker.patch( "httpx.Client.request", side_effect=httpx.ReadTimeout("Request timed out"), ) - with pytest.raises(LinkupTimeoutError): + with pytest.raises(linkup.TimeoutError): client.search(query="query", depth="standard", output_type="searchResults", timeout=1.0) @pytest.mark.asyncio async def test_async_search_timeout( mocker: MockerFixture, - client: LinkupClient, + client: linkup.Client, ) -> None: mocker.patch( "httpx.AsyncClient.request", side_effect=httpx.ReadTimeout("Request timed out"), ) - with pytest.raises(LinkupTimeoutError): + with pytest.raises(linkup.TimeoutError): await client.async_search( query="query", depth="standard", output_type="searchResults", timeout=1.0 ) -def test_research(mocker: MockerFixture, client: LinkupClient) -> None: +def test_research(mocker: MockerFixture, client: linkup.Client) -> None: request_mock = mocker.patch( "httpx.Client.request", return_value=Response( @@ -584,11 +555,11 @@ def test_research(mocker: MockerFixture, client: LinkupClient) -> None: }, timeout=None, ) - assert research_response == LinkupResearchTask( + assert research_response == linkup.ResearchTask( created_at="2026-05-18T00:00:00.000Z", error=None, id="4a44f4e0-eaf0-42eb-8ea4-99311b1d0f01", - input=LinkupResearchTaskInput( + input=linkup.ResearchTaskInput( query="query", output_type="sourcedAnswer", mode="auto", @@ -601,7 +572,7 @@ def test_research(mocker: MockerFixture, client: LinkupClient) -> None: @pytest.mark.asyncio -async def test_async_research(mocker: MockerFixture, client: LinkupClient) -> None: +async def test_async_research(mocker: MockerFixture, client: linkup.Client) -> None: request_mock = mocker.patch( "httpx.AsyncClient.request", return_value=Response( @@ -655,7 +626,7 @@ async def test_async_research(mocker: MockerFixture, client: LinkupClient) -> No {"url": "https://example.com"}, {"url": "https://example.com"}, b'{"markdown": "Some web page content"}', - LinkupFetchResponse(markdown="Some web page content", raw_html=None), + linkup.FetchResponse(markdown="Some web page content", raw_html=None), ), ( { @@ -671,13 +642,13 @@ async def test_async_research(mocker: MockerFixture, client: LinkupClient) -> No "extractImages": True, }, b'{"markdown": "#Some web page content", "rawHtml": "..."}', - LinkupFetchResponse(markdown="#Some web page content", raw_html="..."), + linkup.FetchResponse(markdown="#Some web page content", raw_html="..."), ), ( {"url": "https://example.com", "timeout": 15.0}, {"url": "https://example.com"}, b'{"markdown": "Some web page content"}', - LinkupFetchResponse(markdown="Some web page content", raw_html=None), + linkup.FetchResponse(markdown="Some web page content", raw_html=None), ), ] @@ -693,11 +664,11 @@ async def test_async_research(mocker: MockerFixture, client: LinkupClient) -> No ) def test_fetch( mocker: MockerFixture, - client: LinkupClient, + client: linkup.Client, fetch_kwargs: dict[str, Any], expected_request_params: dict[str, Any], mock_request_response_content: bytes, - expected_fetch_response: LinkupFetchResponse, + expected_fetch_response: linkup.FetchResponse, ) -> None: request_mock = mocker.patch( "httpx.Client.request", @@ -707,7 +678,7 @@ def test_fetch( ), ) - fetch_response: LinkupFetchResponse = client.fetch(**fetch_kwargs) + fetch_response: linkup.FetchResponse = client.fetch(**fetch_kwargs) expected_timeout = fetch_kwargs.get("timeout", None) request_mock.assert_called_once_with( method="POST", @@ -730,11 +701,11 @@ def test_fetch( ) async def test_async_fetch( mocker: MockerFixture, - client: LinkupClient, + client: linkup.Client, fetch_kwargs: dict[str, Any], expected_request_params: dict[str, Any], mock_request_response_content: bytes, - expected_fetch_response: LinkupFetchResponse, + expected_fetch_response: linkup.FetchResponse, ) -> None: request_mock = mocker.patch( "httpx.AsyncClient.request", @@ -744,7 +715,7 @@ async def test_async_fetch( ), ) - fetch_response: LinkupFetchResponse = await client.async_fetch(**fetch_kwargs) + fetch_response: linkup.FetchResponse = await client.async_fetch(**fetch_kwargs) expected_timeout = fetch_kwargs.get("timeout", None) request_mock.assert_called_once_with( method="POST", @@ -767,7 +738,7 @@ async def test_async_fetch( } } """, - LinkupFailedFetchError, + linkup.FailedFetchError, ), ( 400, @@ -785,7 +756,7 @@ async def test_async_fetch( } } """, - LinkupInvalidRequestError, + linkup.InvalidRequestError, ), ( 400, @@ -798,7 +769,7 @@ async def test_async_fetch( } } """, - LinkupFetchResponseTooLargeError, + linkup.FetchResponseTooLargeError, ), ( 400, @@ -811,7 +782,7 @@ async def test_async_fetch( } } """, - LinkupFetchUrlIsFileError, + linkup.FetchUrlIsFileError, ), ] @@ -822,7 +793,7 @@ async def test_async_fetch( ) def test_fetch_error( mocker: MockerFixture, - client: LinkupClient, + client: linkup.Client, mock_request_response_status_code: int, mock_request_response_content: bytes, expected_exception: type[Exception], @@ -847,7 +818,7 @@ def test_fetch_error( ) async def test_async_fetch_error( mocker: MockerFixture, - client: LinkupClient, + client: linkup.Client, mock_request_response_status_code: int, mock_request_response_content: bytes, expected_exception: type[Exception], @@ -867,32 +838,32 @@ async def test_async_fetch_error( def test_fetch_timeout( mocker: MockerFixture, - client: LinkupClient, + client: linkup.Client, ) -> None: mocker.patch( "httpx.Client.request", side_effect=httpx.ReadTimeout("Request timed out"), ) - with pytest.raises(LinkupTimeoutError): + with pytest.raises(linkup.TimeoutError): client.fetch(url="https://example.com", timeout=1.0) @pytest.mark.asyncio async def test_async_fetch_timeout( mocker: MockerFixture, - client: LinkupClient, + client: linkup.Client, ) -> None: mocker.patch( "httpx.AsyncClient.request", side_effect=httpx.ReadTimeout("Request timed out"), ) - with pytest.raises(LinkupTimeoutError): + with pytest.raises(linkup.TimeoutError): await client.async_fetch(url="https://example.com", timeout=1.0) -def test_create_tasks(mocker: MockerFixture, client: LinkupClient) -> None: +def test_create_tasks(mocker: MockerFixture, client: linkup.Client) -> None: request_mock = mocker.patch( "httpx.Client.request", return_value=Response( @@ -944,13 +915,13 @@ def test_create_tasks(mocker: MockerFixture, client: LinkupClient) -> None: tasks_response = client.create_tasks( [ - LinkupSearchTaskInput( + linkup.SearchTaskInput( query="query", depth="deep", output_type="structured", structured_output_schema=Company, ), - LinkupFetchTaskInput( + linkup.FetchTaskInput( url="https://example.com", extract_images=True, ), @@ -980,16 +951,16 @@ def test_create_tasks(mocker: MockerFixture, client: LinkupClient) -> None: ], timeout=None, ) - assert isinstance(tasks_response[0], LinkupSearchTask) + assert isinstance(tasks_response[0], linkup.SearchTask) assert tasks_response[0].input.query == "query" assert tasks_response[0].input.structured_output_schema == {"type": "object"} - assert isinstance(tasks_response[1], LinkupFetchTask) + assert isinstance(tasks_response[1], linkup.FetchTask) assert tasks_response[1].output is not None assert tasks_response[1].output.images is not None assert tasks_response[1].output.images[0].url == "https://example.com/image.png" -def test_create_tasks_research_model(mocker: MockerFixture, client: LinkupClient) -> None: +def test_create_tasks_research_model(mocker: MockerFixture, client: linkup.Client) -> None: request_mock = mocker.patch( "httpx.Client.request", return_value=Response( @@ -1018,7 +989,7 @@ def test_create_tasks_research_model(mocker: MockerFixture, client: LinkupClient tasks_response = client.create_tasks( [ - LinkupResearchTaskInput( + linkup.ResearchTaskInput( query="query", output_type="sourcedAnswer", mode="answer", @@ -1043,12 +1014,12 @@ def test_create_tasks_research_model(mocker: MockerFixture, client: LinkupClient ], timeout=None, ) - assert isinstance(tasks_response[0], LinkupResearchTask) + assert isinstance(tasks_response[0], linkup.ResearchTask) assert tasks_response[0].input.reasoning_depth == "S" @pytest.mark.asyncio -async def test_async_list_research(mocker: MockerFixture, client: LinkupClient) -> None: +async def test_async_list_research(mocker: MockerFixture, client: linkup.Client) -> None: request_mock = mocker.patch( "httpx.AsyncClient.request", return_value=Response( @@ -1092,13 +1063,13 @@ async def test_async_list_research(mocker: MockerFixture, client: LinkupClient) }, timeout=None, ) - assert research_page == LinkupResearchTasksPage( + assert research_page == linkup.ResearchTasksPage( data=[ - LinkupResearchTask( + linkup.ResearchTask( created_at="2026-05-18T00:00:00.000Z", error=None, id="cdedcd9f-ab4a-4404-b8c6-b9ca9dc4c837", - input=LinkupResearchTaskInput( + input=linkup.ResearchTaskInput( query="query", output_type="sourcedAnswer", ), @@ -1108,7 +1079,7 @@ async def test_async_list_research(mocker: MockerFixture, client: LinkupClient) updated_at="2026-05-18T00:00:00.000Z", ) ], - metadata=LinkupTaskMetadata( + metadata=linkup.TaskMetadata( page=2, page_size=5, total=11, @@ -1117,7 +1088,7 @@ async def test_async_list_research(mocker: MockerFixture, client: LinkupClient) ) -def test_list_tasks(mocker: MockerFixture, client: LinkupClient) -> None: +def test_list_tasks(mocker: MockerFixture, client: linkup.Client) -> None: request_mock = mocker.patch( "httpx.Client.request", return_value=Response( @@ -1168,8 +1139,8 @@ def test_list_tasks(mocker: MockerFixture, client: LinkupClient) -> None: }, timeout=None, ) - assert isinstance(tasks_page, LinkupTasksPage) - assert isinstance(tasks_page.data[0], LinkupResearchTask) + assert isinstance(tasks_page, linkup.TasksPage) + assert isinstance(tasks_page.data[0], linkup.ResearchTask) assert tasks_page.data[0].input.query == "query" assert tasks_page.quota.in_flight == 1 @@ -1184,7 +1155,7 @@ def test_list_tasks(mocker: MockerFixture, client: LinkupClient) -> None: def test_client_x402_signer(mock_x402_signer: MagicMock) -> None: - client = LinkupClient(x402_signer=mock_x402_signer) + client = linkup.Client(x402_signer=mock_x402_signer) assert client._x402_signer is mock_x402_signer # noqa: SLF001 assert client._api_key is None # noqa: SLF001 @@ -1193,12 +1164,12 @@ def test_client_both_api_key_and_x402_signer_raises( mock_x402_signer: MagicMock, ) -> None: with pytest.raises(ValueError, match="Cannot provide both"): - LinkupClient(api_key="test-key", x402_signer=mock_x402_signer) + linkup.Client(api_key="test-key", x402_signer=mock_x402_signer) def test_client_x402_no_auth_header( mocker: MockerFixture, - x402_client: LinkupClient, + x402_client: linkup.Client, mock_x402_signer: MagicMock, ) -> None: mock_x402_signer.create_payment_headers.return_value = { @@ -1233,7 +1204,7 @@ def test_client_x402_no_auth_header( def test_x402_retry_sync( mocker: MockerFixture, - x402_client: LinkupClient, + x402_client: linkup.Client, mock_x402_signer: MagicMock, ) -> None: mock_x402_signer.create_payment_headers.return_value = { @@ -1275,7 +1246,7 @@ def test_x402_retry_sync( def test_x402_retry_failure_sync( mocker: MockerFixture, - x402_client: LinkupClient, + x402_client: linkup.Client, mock_x402_signer: MagicMock, ) -> None: mock_x402_signer.create_payment_headers.return_value = { @@ -1293,13 +1264,13 @@ def test_x402_retry_failure_sync( ], ) - with pytest.raises(LinkupUnknownError): + with pytest.raises(linkup.UnknownError): x402_client.search(query="query", depth="standard", output_type="searchResults") def test_x402_signer_error_sync( mocker: MockerFixture, - x402_client: LinkupClient, + x402_client: linkup.Client, mock_x402_signer: MagicMock, ) -> None: mock_x402_signer.create_payment_headers.side_effect = RuntimeError("signing failed") @@ -1312,13 +1283,13 @@ def test_x402_signer_error_sync( ), ) - with pytest.raises(LinkupPaymentRequiredError, match="signing failed"): + with pytest.raises(linkup.PaymentRequiredError, match="signing failed"): x402_client.search(query="query", depth="standard", output_type="searchResults") def test_402_without_signer( mocker: MockerFixture, - client: LinkupClient, + client: linkup.Client, ) -> None: mocker.patch( "httpx.Client.request", @@ -1328,14 +1299,14 @@ def test_402_without_signer( ), ) - with pytest.raises(LinkupPaymentRequiredError): + with pytest.raises(linkup.PaymentRequiredError): client.search(query="query", depth="standard", output_type="searchResults") @pytest.mark.asyncio async def test_x402_retry_async( mocker: MockerFixture, - x402_client: LinkupClient, + x402_client: linkup.Client, mock_x402_signer: MagicMock, ) -> None: mock_x402_signer.async_create_payment_headers = AsyncMock(return_value={"X-Payment": "signed"}) @@ -1378,7 +1349,7 @@ async def test_x402_retry_async( @pytest.mark.asyncio async def test_x402_retry_failure_async( mocker: MockerFixture, - x402_client: LinkupClient, + x402_client: linkup.Client, mock_x402_signer: MagicMock, ) -> None: mock_x402_signer.async_create_payment_headers = AsyncMock(return_value={"X-Payment": "signed"}) @@ -1394,14 +1365,14 @@ async def test_x402_retry_failure_async( ], ) - with pytest.raises(LinkupUnknownError): + with pytest.raises(linkup.UnknownError): await x402_client.async_search(query="query", depth="standard", output_type="searchResults") @pytest.mark.asyncio async def test_x402_signer_error_async( mocker: MockerFixture, - x402_client: LinkupClient, + x402_client: linkup.Client, mock_x402_signer: MagicMock, ) -> None: mock_x402_signer.async_create_payment_headers = AsyncMock( @@ -1416,14 +1387,14 @@ async def test_x402_signer_error_async( ), ) - with pytest.raises(LinkupPaymentRequiredError, match="signing failed"): + with pytest.raises(linkup.PaymentRequiredError, match="signing failed"): await x402_client.async_search(query="query", depth="standard", output_type="searchResults") @pytest.mark.asyncio async def test_402_without_signer_async( mocker: MockerFixture, - client: LinkupClient, + client: linkup.Client, ) -> None: mocker.patch( "httpx.AsyncClient.request", @@ -1433,5 +1404,5 @@ async def test_402_without_signer_async( ), ) - with pytest.raises(LinkupPaymentRequiredError): + with pytest.raises(linkup.PaymentRequiredError): await client.async_search(query="query", depth="standard", output_type="searchResults") diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index d7f09a8..a2e5a5b 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -3,15 +3,15 @@ import pytest -from linkup import LinkupClient +import linkup from linkup.x402 import LinkupX402Signer @pytest.fixture(scope="session") -def client() -> LinkupClient: +def client() -> linkup.Client: if os.getenv("LINKUP_API_KEY") is None: os.environ["LINKUP_API_KEY"] = "" - return LinkupClient() + return linkup.Client() @pytest.fixture @@ -20,5 +20,5 @@ def mock_x402_signer() -> MagicMock: @pytest.fixture -def x402_client(mock_x402_signer: MagicMock) -> LinkupClient: - return LinkupClient(x402_signer=mock_x402_signer) +def x402_client(mock_x402_signer: MagicMock) -> linkup.Client: + return linkup.Client(x402_signer=mock_x402_signer) diff --git a/tests/unit/init_test.py b/tests/unit/init_test.py new file mode 100644 index 0000000..52c7d6e --- /dev/null +++ b/tests/unit/init_test.py @@ -0,0 +1,13 @@ +import linkup + + +def test_linkup_exports_have_prefix_stripped_aliases() -> None: + prefix = "Linkup" + for name in linkup.__all__: + if not name.startswith(prefix): + continue + + alias = name.removeprefix(prefix) + + assert alias in linkup.__all__ + assert getattr(linkup, alias) is getattr(linkup, name)