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/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..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?", @@ -292,8 +288,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 bcb190d..4737b12 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" @@ -106,7 +103,6 @@ select = [ ] [tool.ruff.lint.extend-per-file-ignores] -"examples/*.py" = ["D"] "src/linkup/__init__.py" = ["D104"] "tests/**/*test.py" = ["D", "S101"] @@ -121,11 +117,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 +135,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 +142,3 @@ required-version = ">=0.10.0,<0.11.0" [build-system] build-backend = "hatchling.build" requires = ["hatchling"] - 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)