diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..fa24419 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,29 @@ +root = true + +[*] +charset = utf-8 +insert_final_newline = true + +[*.md] +indent_style = space +indent_size = 2 +max_line_length = 100 + +[*.py] +indent_style = space +indent_size = 4 +max_line_length = 100 + +[*.toml] +indent_style = space +indent_size = 2 +max_line_length = 100 + +[*.yaml] +indent_style = space +indent_size = 2 +max_line_length = 100 + +[Makefile] +indent_style = tab +indent_size = 8 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 40d29c2..f827401 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,11 +1,12 @@ ## Description - + ## Checklist - + -- [ ] I have run the tests locally with `make install-dev` and `make test` +- [ ] I have installed pre-commit on this project (for instance with the `make install-dev` command) + **before** creating any commit, or I have run successfully the `make lint` command on my changes +- [ ] I have run successfully the `make test` command on my changes - [ ] I have updated the `README.md` if my changes affected it -- [ ] I have updated the package version in `linkup/_version.py` and `pyproject.toml` if I plan on creating a new release diff --git a/.gitignore b/.gitignore index 0fd741c..47f879f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,170 +1,16 @@ -# Byte-compiled / optimized / DLL files +# Python-generated files +.idea/ +.ipynb_checkpoints/ +.venv/ +.vscode/ __pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python build/ -develop-eggs/ dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ .coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control -.pdm.toml -.pdm-python -.pdm-build/ - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments .env -.venv* -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site +*.egg-info +*.py[oc] -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - - -# OSX +# MacOS stuff .DS_Store - -# Dev -playground.ipynb -playground.py diff --git a/.mdformat.toml b/.mdformat.toml new file mode 100644 index 0000000..dbe02d6 --- /dev/null +++ b/.mdformat.toml @@ -0,0 +1,2 @@ +number = true +wrap = 100 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 97cfbe5..a9c4758 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,21 +1,39 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: check-added-large-files - id: check-merge-conflict - id: detect-private-key - id: end-of-file-fixer - - id: trailing-whitespace + - id: name-tests-test + - id: no-commit-to-branch + args: [--branch, main] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.8.0 + rev: v0.13.0 hooks: - id: ruff args: [--fix] - id: ruff-format + - repo: https://github.com/hukkin/mdformat + rev: 0.7.22 + hooks: + - id: mdformat + exclude: CHANGELOG.md + + - repo: https://github.com/ComPWA/taplo-pre-commit + rev: v0.9.3 + hooks: + - id: taplo-format + + - repo: https://github.com/google/yamlfmt + rev: v0.17.2 + hooks: + - id: yamlfmt + - repo: https://github.com/gitleaks/gitleaks - rev: v8.21.2 + rev: v8.28.0 hooks: - id: gitleaks diff --git a/.python-version b/.python-version index c8cfe39..bd28b9c 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.10 +3.9 diff --git a/.taplo.toml b/.taplo.toml new file mode 100644 index 0000000..e48c956 --- /dev/null +++ b/.taplo.toml @@ -0,0 +1,7 @@ +[formatting] +align_comments = false +allowed_blank_lines = 1 +column_width = 100 +reorder_arrays = true +reorder_inline_tables = true +reorder_keys = true diff --git a/.yamlfmt.yaml b/.yamlfmt.yaml new file mode 100644 index 0000000..8b98c72 --- /dev/null +++ b/.yamlfmt.yaml @@ -0,0 +1,4 @@ +formatter: + type: basic + retain_line_breaks_single: true + max_line_length: 100 diff --git a/Makefile b/Makefile index b36c26c..e01d9c0 100644 --- a/Makefile +++ b/Makefile @@ -4,12 +4,30 @@ install-dev: @$(MAKE) install uv run pre-commit install +lint: + SKIP=no-commit-to-branch uv run pre-commit run --all-files + +test-mypy: + @# Avoid running mypy on the whole directory ("./") to avoid potential conflicts with files with the same name (e.g. between different types of tests) + uv run mypy ./src/ + uv run mypy ./tests/unit/ +test-pytest: + uv run pytest --cov=src/linkup/ ./tests/unit/ test: - @echo "Running tests..." - uv run pre-commit run --all-files - uv run mypy . - # Follow the test practices recommanded by LangChain (v0.3) - # See https://python.langchain.com/docs/contributing/how_to/integrations/standard_tests/ - uv run pytest --cov=src/linkup/ --cov-report term-missing --disable-socket --allow-unix-socket tests/unit_tests - # TODO: uncomment the following line when integration tests are ready - # pytest tests/integration_tests + @$(MAKE) test-mypy + @echo + @$(MAKE) test-pytest + +update-uv: + uv lock --upgrade + uv sync +update-pre-commit: + uv run pre-commit autoupdate + +clean: + rm -rf dist/ + rm -f .coverage + rm -rf .mypy_cache/ + rm -rf .pytest_cache/ + rm -rf .ruff_cache/ + rm -rf **/*/__pycache__/ diff --git a/README.md b/README.md index 798d6a5..798f73b 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,6 @@ ![PyPI - Downloads](https://img.shields.io/pypi/dm/linkup-sdk) [![Discord](https://img.shields.io/discord/1303713168916348959?color=7289da&logo=discord&logoColor=white)](https://discord.gg/9q9mCYJa86) - A [Python SDK](https://docs.linkup.so/pages/sdk/python/python) for the [Linkup API](https://www.linkup.so/), allowing easy integration with Linkup's services. 🐍 diff --git a/examples/1_direct_search_results.py b/examples/1_direct_search_results.py index b1d8748..34769ba 100644 --- a/examples/1_direct_search_results.py +++ b/examples/1_direct_search_results.py @@ -3,6 +3,8 @@ for instance in a RAG system, with the output_type parameter set to "searchResults". """ +from rich import print + from linkup import LinkupClient client = LinkupClient() diff --git a/examples/2_sourced_answer_search.py b/examples/2_sourced_answer_search.py index 813c8e2..0f565f4 100644 --- a/examples/2_sourced_answer_search.py +++ b/examples/2_sourced_answer_search.py @@ -4,6 +4,8 @@ along with the sources supporting it. """ +from rich import print + from linkup import LinkupClient client = LinkupClient() diff --git a/examples/3_structured_search.py b/examples/3_structured_search.py index 14a5425..00e7a1b 100644 --- a/examples/3_structured_search.py +++ b/examples/3_structured_search.py @@ -4,9 +4,8 @@ documented schema to steer the Linkup search in any direction. """ -from typing import List - from pydantic import BaseModel, Field +from rich import print from linkup import LinkupClient @@ -17,7 +16,7 @@ class Event(BaseModel): class Events(BaseModel): - events: List[Event] = Field(description="The list of events") + events: list[Event] = Field(description="The list of events") client = LinkupClient() diff --git a/examples/4_asynchronous_search.py b/examples/4_asynchronous_search.py index 22fa949..6ea3d48 100644 --- a/examples/4_asynchronous_search.py +++ b/examples/4_asynchronous_search.py @@ -6,13 +6,14 @@ import asyncio import time -from typing import List + +from rich import print from linkup import LinkupClient client = LinkupClient() -queries: List[str] = [ +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?", ] @@ -27,7 +28,7 @@ async def search(idx: int, query: str) -> None: depth="standard", # or "deep" output_type="searchResults", # or "sourcedAnswer" or "structured" ) - print(f"{idx+1}: {time.time() - t0:.3f}s") + print(f"{idx + 1}: {time.time() - t0:.3f}s") print(response) print("-" * 100) diff --git a/pyproject.toml b/pyproject.toml index c1130c3..5e627d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,33 +1,28 @@ [project] -name = "linkup-sdk" -version = "0.2.9" +authors = [{ email = "contact@linkup.so", name = "LINKUP TECHNOLOGIES" }] description = "A Python Client SDK for the Linkup API" +keywords = ["api", "client", "linkup", "sdk", "search"] +license = "MIT" +name = "linkup-sdk" readme = "README.md" requires-python = ">=3.9" -authors = [ - { name = "LINKUP TECHNOLOGIES", email = "contact@linkup.so" } -] -keywords = ["linkup", "api", "sdk", "client", "search"] -license = "MIT" +version = "0.2.9" classifiers = [ "Intended Audience :: Developers", - "Topic :: Software Development :: Libraries :: Python Modules", "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent" + "Operating System :: OS Independent", + "Topic :: Software Development :: Libraries :: Python Modules", ] -dependencies = [ - "httpx", - "pydantic" -] +dependencies = ["httpx", "pydantic"] [project.optional-dependencies] build = ["uv>=0.8.0,<0.9.0"] # For python-semantic-release build command, used in GitHub actions [project.urls] -Homepage = "https://github.com/LinkupPlatform/linkup-python-sdk" Documentation = "https://github.com/LinkupPlatform/linkup-python-sdk#readme" +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" @@ -35,56 +30,56 @@ Tracker = "https://github.com/LinkupPlatform/linkup-python-sdk/issues" dev = [ "mypy>=1.16.1", "pre-commit>=4.2.0", - "pytest>=8.4.1", "pytest-asyncio>=1.0.0", "pytest-cov>=6.2.1", "pytest-mock>=3.14.1", - "pytest-socket>=0.7.0", + "pytest>=8.4.1", + "rich>=14.1.0", ] [tool.mypy] -exclude = ['^tests/', 'venv/', '.venv/'] strict = true +warn_unreachable = true [tool.pytest.ini_options] asyncio_default_fixture_loop_scope = "function" [tool.coverage.report] -exclude_also = ["raise ValueError", "raise TypeError"] +exclude_also = ["raise TypeError", "raise ValueError"] +show_missing = true +skip_covered = true [tool.ruff] line-length = 100 target-version = "py39" [tool.ruff.lint] -select = [ - "E", # pycodestyle - "F", # pyflakes - "I", # isort - "S", # flake8-bandit -] +select = ["E", "F", "I", "S", "UP"] [tool.ruff.lint.extend-per-file-ignores] -"tests/**/*_test.py" = ["S101"] # Use of assert detected +"tests/**/*_test.py" = ["S101"] [build-system] -requires = ["hatchling"] build-backend = "hatchling.build" +requires = ["hatchling"] [tool.hatch.build.targets.wheel] packages = ["src/linkup"] # Because project and source code directory names differ [tool.semantic_release] -commit_message = "chore: release v{version}\n\nAutomatically generated by python-semantic-release\n\n[skip ci]" # [skip ci] is needed to recursively calling the release CI allow_zero_version = true -major_on_zero = false build_command = """ python -m pip install -e '.[build]' uv lock --upgrade-package "$PACKAGE_NAME" git add uv.lock uv build """ +commit_message = "chore: release v{version}\n\nAutomatically generated by python-semantic-release\n\n[skip ci]" # [skip ci] is needed to recursively calling the release CI +major_on_zero = false version_toml = ["pyproject.toml:project.version"] [tool.semantic_release.commit_parser_options] parse_squash_commits = false + +[tool.uv] +required-version = ">=0.8.0,<0.9.0" diff --git a/src/linkup/client.py b/src/linkup/client.py index 7aaacab..cf0b7f6 100644 --- a/src/linkup/client.py +++ b/src/linkup/client.py @@ -1,7 +1,7 @@ import json import os from datetime import date -from typing import Any, Dict, Literal, Optional, Type, Union +from typing import Any, Literal, Optional, Union import httpx from pydantic import BaseModel @@ -51,7 +51,7 @@ def search( query: str, depth: Literal["standard", "deep"], output_type: Literal["searchResults", "sourcedAnswer", "structured"], - structured_output_schema: Union[Type[BaseModel], str, None] = None, + 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, @@ -72,8 +72,7 @@ def search( structured_output_schema: If output_type is "structured", specify the schema of the output. Supported formats are a pydantic.BaseModel or a string representing a valid object JSON schema. - include_images: If output_type is "searchResults", specifies if the response can include - images. Default to False. + 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 @@ -97,7 +96,7 @@ 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]]] = self._get_search_params( + params: dict[str, Union[str, bool, list[str]]] = self._get_search_params( query=query, depth=depth, output_type=output_type, @@ -129,7 +128,7 @@ async def async_search( query: str, depth: Literal["standard", "deep"], output_type: Literal["searchResults", "sourcedAnswer", "structured"], - structured_output_schema: Union[Type[BaseModel], str, None] = None, + 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, @@ -150,8 +149,7 @@ async def async_search( structured_output_schema: If output_type is "structured", specify the schema of the output. Supported formats are a pydantic.BaseModel or a string representing a valid object JSON schema. - include_images: If output_type is "searchResults", specifies if the response can include - images. Default to False + 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 @@ -174,7 +172,7 @@ async def async_search( LinkupAuthenticationError: If the Linkup API key is invalid, or there is no more credit available. """ - params: Dict[str, Union[str, bool, list[str]]] = self._get_search_params( + params: dict[str, Union[str, bool, list[str]]] = self._get_search_params( query=query, depth=depth, output_type=output_type, @@ -204,7 +202,7 @@ async def async_search( def _user_agent(self) -> str: # pragma: no cover return f"Linkup-Python/{self.__version__}" - def _headers(self) -> Dict[str, str]: # pragma: no cover + def _headers(self) -> dict[str, str]: # pragma: no cover return { "Authorization": f"Bearer {self.__api_key}", "User-Agent": self._user_agent(), @@ -312,14 +310,14 @@ def _get_search_params( query: str, depth: Literal["standard", "deep"], output_type: Literal["searchResults", "sourcedAnswer", "structured"], - structured_output_schema: Union[Type[BaseModel], str, None], + structured_output_schema: Union[type[BaseModel], str, None], include_images: bool, from_date: Union[date, None], include_domains: Union[list[str], None], exclude_domains: Union[list[str], None], to_date: Union[date, None], - ) -> Dict[str, Union[str, bool, list[str]]]: - params: Dict[str, Union[str, bool, list[str]]] = dict( + ) -> dict[str, Union[str, bool, list[str]]]: + params: dict[str, Union[str, bool, list[str]]] = dict( q=query, depth=depth, outputType=output_type, @@ -330,7 +328,7 @@ def _get_search_params( if isinstance(structured_output_schema, str): params["structuredOutputSchema"] = structured_output_schema elif issubclass(structured_output_schema, BaseModel): - json_schema: Dict[str, Any] = structured_output_schema.model_json_schema() + json_schema: dict[str, Any] = structured_output_schema.model_json_schema() params["structuredOutputSchema"] = json.dumps(json_schema) else: raise TypeError( @@ -351,10 +349,10 @@ def _validate_search_response( self, response: httpx.Response, output_type: Literal["searchResults", "sourcedAnswer", "structured"], - structured_output_schema: Union[Type[BaseModel], str, None], + structured_output_schema: Union[type[BaseModel], str, None], ) -> Any: response_data: Any = response.json() - output_base_model: Optional[Type[BaseModel]] = None + output_base_model: Optional[type[BaseModel]] = None if output_type == "searchResults": output_base_model = LinkupSearchResults elif output_type == "sourcedAnswer": diff --git a/src/linkup/types.py b/src/linkup/types.py index 4e7d7d6..c975bea 100644 --- a/src/linkup/types.py +++ b/src/linkup/types.py @@ -1,4 +1,4 @@ -from typing import List, Union +from typing import Literal, Union from pydantic import BaseModel @@ -8,13 +8,13 @@ class LinkupSearchTextResult(BaseModel): A text result from a Linkup search. Attributes: - type: The type of the search result, in this case 'text' + type: The type of the search result, in this case "text". name: The name of the search result. url: The URL of the search result. content: The text of the search result. """ - type: str + type: Literal["text"] name: str url: str content: str @@ -25,12 +25,12 @@ class LinkupSearchImageResult(BaseModel): An image result from a Linkup search. Attributes: - type: The type of the search result, in this case 'image' + type: The type of the search result, in this case "image". name: The name of the image result. url: The URL of the image result. """ - type: str + type: Literal["image"] name: str url: str @@ -43,7 +43,7 @@ class LinkupSearchResults(BaseModel): results: The results of the Linkup search. """ - results: List[Union[LinkupSearchTextResult, LinkupSearchImageResult]] + results: list[Union[LinkupSearchTextResult, LinkupSearchImageResult]] class LinkupSource(BaseModel): @@ -53,12 +53,12 @@ class LinkupSource(BaseModel): Attributes: name: The name of the source. url: The URL of the source. - snippet: The text excerpt supporting the Linkup answer. + snippet: The text excerpt supporting the Linkup answer. Can be empty for image sources. """ name: str url: str - snippet: str + snippet: str = "" class LinkupSourcedAnswer(BaseModel): @@ -71,4 +71,4 @@ class LinkupSourcedAnswer(BaseModel): """ answer: str - sources: List[Union[LinkupSource, LinkupSearchTextResult, LinkupSearchImageResult]] + sources: list[LinkupSource] diff --git a/tests/unit_tests/client_test.py b/tests/unit/client_test.py similarity index 76% rename from tests/unit_tests/client_test.py rename to tests/unit/client_test.py index a74b48d..09bf205 100644 --- a/tests/unit_tests/client_test.py +++ b/tests/unit/client_test.py @@ -1,5 +1,5 @@ import json -from typing import Any, List, Type, Union +from typing import Any, Union import pytest from httpx import Response @@ -27,7 +27,7 @@ class Company(BaseModel): name: str creation_date: str website_url: str - founders_names: List[str] + founders_names: list[str] def test_search_search_results(mocker: MockerFixture, client: LinkupClient) -> None: @@ -76,19 +76,17 @@ def test_search_sourced_answer(mocker: MockerFixture, client: LinkupClient) -> N "sources": [ { "name": "foo", - "url": "https://foo.bar/baz", - "snippet": "foo bar baz qux" + "url": "https://foo.com", + "snippet": "lorem ipsum dolor sit amet" }, { - "type": "text", "name": "bar", - "url": "https://foo.bar/baz", - "content": "foo bar baz qux" + "url": "https://bar.com", + "snippet": "consectetur adipiscing elit" }, { - "type": "image", "name": "baz", - "url": "https://foo.bar/baz" + "url": "https://baz.com" } ] } @@ -108,19 +106,16 @@ def test_search_sourced_answer(mocker: MockerFixture, client: LinkupClient) -> N assert isinstance(response.sources[0], LinkupSource) assert response.answer == "foo bar baz" assert response.sources[0].name == "foo" - assert response.sources[0].url == "https://foo.bar/baz" - assert response.sources[0].snippet == "foo bar baz qux" - assert isinstance(response.sources[1], LinkupSearchTextResult) - assert response.answer == "foo bar baz" - assert response.sources[1].type == "text" + assert response.sources[0].url == "https://foo.com" + assert response.sources[0].snippet == "lorem ipsum dolor sit amet" + assert isinstance(response.sources[1], LinkupSource) assert response.sources[1].name == "bar" - assert response.sources[1].url == "https://foo.bar/baz" - assert response.sources[1].content == "foo bar baz qux" - assert isinstance(response.sources[2], LinkupSearchImageResult) - assert response.answer == "foo bar baz" - assert response.sources[2].type == "image" + assert response.sources[1].url == "https://bar.com" + assert response.sources[1].snippet == "consectetur adipiscing elit" + assert isinstance(response.sources[2], LinkupSource) assert response.sources[2].name == "baz" - assert response.sources[2].url == "https://foo.bar/baz" + assert response.sources[2].url == "https://baz.com" + assert response.sources[2].snippet == "" @pytest.mark.parametrize( @@ -130,12 +125,8 @@ def test_search_sourced_answer(mocker: MockerFixture, client: LinkupClient) -> N def test_search_structured_search( mocker: MockerFixture, client: LinkupClient, - structured_output_schema: Union[Type[BaseModel], str], + structured_output_schema: Union[type[BaseModel], str], ) -> None: - query = "What is Linkup, the new French AI company?" - depth = "standard" - output_type = "structured" - mocker.patch( "linkup.client.LinkupClient._request", return_value=Response( @@ -146,9 +137,9 @@ def test_search_structured_search( ) response: Any = client.search( - query=query, - depth=depth, - output_type=output_type, + query="What is Linkup, the new French AI company?", + depth="standard", + output_type="structured", structured_output_schema=structured_output_schema, ) @@ -170,10 +161,6 @@ def test_search_structured_search( def test_search_authorization_error(mocker: MockerFixture, client: LinkupClient) -> None: - query = "What is Linkup, the new French AI company?" - depth = "standard" - output_type = "searchResults" - mock_response = mocker.Mock() mock_response.status_code = 403 mock_response.json.return_value = { @@ -191,14 +178,14 @@ def test_search_authorization_error(mocker: MockerFixture, client: LinkupClient) ) with pytest.raises(LinkupAuthenticationError): - client.search(query=query, depth=depth, output_type=output_type) + client.search( + query="What is Linkup, the new French AI company?", + depth="standard", + output_type="searchResults", + ) def test_search_authentication_error(mocker: MockerFixture, client: LinkupClient) -> None: - query = "What is Linkup, the new French AI company?" - depth = "standard" - output_type = "searchResults" - mock_response = mocker.Mock() mock_response.status_code = 401 mock_response.json.return_value = { @@ -216,7 +203,11 @@ def test_search_authentication_error(mocker: MockerFixture, client: LinkupClient ) with pytest.raises(LinkupAuthenticationError): - client.search(query=query, depth=depth, output_type=output_type) + client.search( + query="What is Linkup, the new French AI company?", + depth="standard", + output_type="searchResults", + ) def test_search_insufficient_credit_error(mocker: MockerFixture, client: LinkupClient) -> None: @@ -286,26 +277,6 @@ def test_search_structured_search_invalid_request( mocker: MockerFixture, client: LinkupClient, ) -> None: - query = "What is Linkup, the new French AI company?" - depth = "standard" - output_type = "structured" - # Schema corresponding to the Company class, without "type": "object" - structured_output_schema = json.dumps( - { - "properties": { - "name": {"title": "Name", "type": "string"}, - "creation_date": {"title": "Creation Date", "type": "string"}, - "website_url": {"title": "Website Url", "type": "string"}, - "founders_names": { - "items": {"type": "string"}, - "title": "Founders Names", - "type": "array", - }, - }, - "required": ["name", "creation_date", "website_url", "founders_names"], - "title": "Company", - } - ) mock_response = mocker.Mock() mock_response.status_code = 400 mock_response.json.return_value = { @@ -326,10 +297,26 @@ def test_search_structured_search_invalid_request( with pytest.raises(LinkupInvalidRequestError): client.search( - query=query, - depth=depth, - output_type=output_type, - structured_output_schema=structured_output_schema, + query="What is Linkup, the new French AI company?", + depth="standard", + output_type="structured", + # Schema corresponding to the Company class, without "type": "object" + structured_output_schema=json.dumps( + { + "properties": { + "name": {"title": "Name", "type": "string"}, + "creation_date": {"title": "Creation Date", "type": "string"}, + "website_url": {"title": "Website Url", "type": "string"}, + "founders_names": { + "items": {"type": "string"}, + "title": "Founders Names", + "type": "array", + }, + }, + "required": ["name", "creation_date", "website_url", "founders_names"], + "title": "Company", + } + ), ) @@ -354,10 +341,6 @@ def test_search_no_result_error(mocker: MockerFixture, client: LinkupClient) -> def test_search_unknown_error(mocker: MockerFixture, client: LinkupClient) -> None: - query = "What is Linkup, the new French AI company?" - depth = "standard" - output_type = "searchResults" - mock_response = mocker.Mock() mock_response.status_code = 500 mock_response.json.return_value = { @@ -375,7 +358,11 @@ def test_search_unknown_error(mocker: MockerFixture, client: LinkupClient) -> No ) with pytest.raises(LinkupUnknownError): - client.search(query=query, depth=depth, output_type=output_type) + client.search( + query="What is Linkup, the new French AI company?", + depth="standard", + output_type="searchResults", + ) @pytest.mark.asyncio @@ -428,19 +415,17 @@ async def test_async_search_sourced_answer(mocker: MockerFixture, client: Linkup "sources": [ { "name": "foo", - "url": "https://foo.bar/baz", - "snippet": "foo bar baz qux" + "url": "https://foo.com", + "snippet": "lorem ipsum dolor sit amet" }, { - "type": "text", "name": "bar", - "url": "https://foo.bar/baz", - "content": "foo bar baz qux" + "url": "https://bar.com", + "snippet": "consectetur adipiscing elit" }, { - "type": "image", "name": "baz", - "url": "https://foo.bar/baz" + "url": "https://baz.com" } ] } @@ -462,19 +447,16 @@ async def test_async_search_sourced_answer(mocker: MockerFixture, client: Linkup assert isinstance(response.sources[0], LinkupSource) assert response.answer == "foo bar baz" assert response.sources[0].name == "foo" - assert response.sources[0].url == "https://foo.bar/baz" - assert response.sources[0].snippet == "foo bar baz qux" - assert isinstance(response.sources[1], LinkupSearchTextResult) - assert response.answer == "foo bar baz" - assert response.sources[1].type == "text" + assert response.sources[0].url == "https://foo.com" + assert response.sources[0].snippet == "lorem ipsum dolor sit amet" + assert isinstance(response.sources[1], LinkupSource) assert response.sources[1].name == "bar" - assert response.sources[1].url == "https://foo.bar/baz" - assert response.sources[1].content == "foo bar baz qux" - assert isinstance(response.sources[2], LinkupSearchImageResult) - assert response.answer == "foo bar baz" - assert response.sources[2].type == "image" + assert response.sources[1].url == "https://bar.com" + assert response.sources[1].snippet == "consectetur adipiscing elit" + assert isinstance(response.sources[2], LinkupSource) assert response.sources[2].name == "baz" - assert response.sources[2].url == "https://foo.bar/baz" + assert response.sources[2].url == "https://baz.com" + assert response.sources[2].snippet == "" @pytest.mark.asyncio @@ -485,12 +467,8 @@ async def test_async_search_sourced_answer(mocker: MockerFixture, client: Linkup async def test_async_search_structured_search( mocker: MockerFixture, client: LinkupClient, - structured_output_schema: Union[Type[BaseModel], str], + structured_output_schema: Union[type[BaseModel], str], ) -> None: - query = "What is Linkup, the new French AI company?" - depth = "standard" - output_type = "structured" - mocker.patch( "linkup.client.LinkupClient._async_request", return_value=Response( @@ -501,9 +479,9 @@ async def test_async_search_structured_search( ) response: Any = await client.async_search( - query=query, - depth=depth, - output_type=output_type, + query="What is Linkup, the new French AI company?", + depth="standard", + output_type="structured", structured_output_schema=structured_output_schema, ) @@ -528,10 +506,6 @@ async def test_async_search_structured_search( async def test_async_search_authorization_error( mocker: MockerFixture, client: LinkupClient ) -> None: - query = "What is Linkup, the new French AI company?" - depth = "standard" - output_type = "searchResults" - mock_response = mocker.Mock() mock_response.status_code = 403 mock_response.json.return_value = { @@ -549,17 +523,17 @@ async def test_async_search_authorization_error( ) with pytest.raises(LinkupAuthenticationError): - await client.async_search(query=query, depth=depth, output_type=output_type) + await client.async_search( + query="What is Linkup, the new French AI company?", + depth="standard", + output_type="searchResults", + ) @pytest.mark.asyncio async def test_async_search_authentication_error( mocker: MockerFixture, client: LinkupClient ) -> None: - query = "What is Linkup, the new French AI company?" - depth = "standard" - output_type = "searchResults" - mock_response = mocker.Mock() mock_response.status_code = 401 mock_response.json.return_value = { @@ -577,7 +551,11 @@ async def test_async_search_authentication_error( ) with pytest.raises(LinkupAuthenticationError): - await client.async_search(query=query, depth=depth, output_type=output_type) + await client.async_search( + query="What is Linkup, the new French AI company?", + depth="standard", + output_type="searchResults", + ) @pytest.mark.asyncio @@ -654,27 +632,6 @@ async def test_async_search_structured_search_invalid_request( mocker: MockerFixture, client: LinkupClient, ) -> None: - query = "What is Linkup, the new French AI company?" - depth = "standard" - output_type = "structured" - # Schema corresponding to the Company class, without "type": "object" - structured_output_schema = json.dumps( - { - "properties": { - "name": {"title": "Name", "type": "string"}, - "creation_date": {"title": "Creation Date", "type": "string"}, - "website_url": {"title": "Website Url", "type": "string"}, - "founders_names": { - "items": {"type": "string"}, - "title": "Founders Names", - "type": "array", - }, - }, - "required": ["name", "creation_date", "website_url", "founders_names"], - "title": "Company", - } - ) - mock_response = mocker.Mock() mock_response.status_code = 400 mock_response.json.return_value = { @@ -698,10 +655,26 @@ async def test_async_search_structured_search_invalid_request( with pytest.raises(LinkupInvalidRequestError): await client.async_search( - query=query, - depth=depth, - output_type=output_type, - structured_output_schema=structured_output_schema, + query="What is Linkup, the new French AI company?", + depth="standard", + output_type="structured", + # Schema corresponding to the Company class, without "type": "object" + structured_output_schema=json.dumps( + { + "properties": { + "name": {"title": "Name", "type": "string"}, + "creation_date": {"title": "Creation Date", "type": "string"}, + "website_url": {"title": "Website Url", "type": "string"}, + "founders_names": { + "items": {"type": "string"}, + "title": "Founders Names", + "type": "array", + }, + }, + "required": ["name", "creation_date", "website_url", "founders_names"], + "title": "Company", + } + ), ) @@ -728,10 +701,6 @@ async def test_async_search_no_result_error(mocker: MockerFixture, client: Linku @pytest.mark.asyncio async def test_async_search_unknown_error(mocker: MockerFixture, client: LinkupClient) -> None: - query = "What is Linkup, the new French AI company?" - depth = "standard" - output_type = "searchResults" - mock_response = mocker.Mock() mock_response.status_code = 500 mock_response.json.return_value = { @@ -749,4 +718,8 @@ async def test_async_search_unknown_error(mocker: MockerFixture, client: LinkupC ) with pytest.raises(LinkupUnknownError): - await client.async_search(query=query, depth=depth, output_type=output_type) + await client.async_search( + query="What is Linkup, the new French AI company?", + depth="standard", + output_type="searchResults", + ) diff --git a/tests/unit_tests/conftest.py b/tests/unit/conftest.py similarity index 100% rename from tests/unit_tests/conftest.py rename to tests/unit/conftest.py diff --git a/uv.lock b/uv.lock index 290a418..5665cc5 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,10 @@ version = 1 revision = 3 requires-python = ">=3.9" +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version < '3.10'", +] [[package]] name = "annotated-types" @@ -257,7 +261,7 @@ dev = [ { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-mock" }, - { name = "pytest-socket" }, + { name = "rich" }, ] [package.metadata] @@ -276,7 +280,46 @@ dev = [ { name = "pytest-asyncio", specifier = ">=1.0.0" }, { name = "pytest-cov", specifier = ">=6.2.1" }, { name = "pytest-mock", specifier = ">=3.14.1" }, - { name = "pytest-socket", specifier = ">=0.7.0" }, + { name = "rich", specifier = ">=14.1.0" }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "mdurl", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "mdurl", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] [[package]] @@ -585,18 +628,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/05/77b60e520511c53d1c1ca75f1930c7dd8e971d0c4379b7f4b3f9644685ba/pytest_mock-3.14.1-py3-none-any.whl", hash = "sha256:178aefcd11307d874b4cd3100344e7e2d888d9791a6a1d9bfe90fbc1b74fd1d0", size = 9923, upload-time = "2025-05-26T13:58:43.487Z" }, ] -[[package]] -name = "pytest-socket" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pytest" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/05/ff/90c7e1e746baf3d62ce864c479fd53410b534818b9437413903596f81580/pytest_socket-0.7.0.tar.gz", hash = "sha256:71ab048cbbcb085c15a4423b73b619a8b35d6a307f46f78ea46be51b1b7e11b3", size = 12389, upload-time = "2024-01-28T20:17:23.177Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/19/58/5d14cb5cb59409e491ebe816c47bf81423cd03098ea92281336320ae5681/pytest_socket-0.7.0-py3-none-any.whl", hash = "sha256:7e0f4642177d55d317bbd58fc68c6bd9048d6eadb2d46a89307fa9221336ce45", size = 6754, upload-time = "2024-01-28T20:17:22.105Z" }, -] - [[package]] name = "pyyaml" version = "6.0.2" @@ -650,6 +681,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/87/5124b1c1f2412bb95c59ec481eaf936cd32f0fe2a7b16b97b81c4c017a6a/PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8", size = 162312, upload-time = "2024-08-06T20:33:49.073Z" }, ] +[[package]] +name = "rich" +version = "14.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/75/af448d8e52bf1d8fa6a9d089ca6c07ff4453d86c65c145d0a300bb073b9b/rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8", size = 224441, upload-time = "2025-07-25T07:32:58.125Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f", size = 243368, upload-time = "2025-07-25T07:32:56.73Z" }, +] + [[package]] name = "sniffio" version = "1.3.1"