diff --git a/pyproject.toml b/pyproject.toml index 0227fdd..d55086d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,7 +57,35 @@ target-version = "py39" [tool.ruff.lint] extend-ignore = ["D107"] pydocstyle = { convention = "google" } -select = ["D", "E", "F", "I", "S", "UP"] +# TODO: enable commented out rules and fix errors +select = [ + # "A", # flake8-builtins: avoid shadowing built-in names + # "ANN", # flake8-annotations: check for missing type annotations + "ASYNC", # flake8-async: enforce best practices for async code + "B", # flake8-bugbear: find likely bugs and design problems in your program + "C4", # flake8-comprehensions: enforce best practices for list/set/dict comprehensions + "D", # pydocstyle: check compliance with docstring conventions + "E", # pycodestyle errors: check for PEP 8 style convention errors + "F", # pyflakes: check for Python source file errors + # "FA", # flake8-future-annotations: enforce usage of future annotations when relevant + "I", # isort: enforce import sorting + "ICN", # flake8-import-conventions: enforce general import conventions + "ISC", # flake8-implicit-str-concat: check for invalid implicit or explicit string concatenation + "N", # pep8-naming: check for naming convention violations + "PERF", # perflint: check for performance anti-patterns + # "PT", # flake8-pytest-style: check common style issues and inconsistencies in pytest-based tests + "PTH", # flake8-use-pathlib: enforce usage of pathlib for path manipulations instead of os.path + "Q", # flake8-quotes: enforce consistent string quote usage + "RET", # flake8-return: enforce best practices for return statements + "RSE", # flake8-raise: enforce best practices for raise statements + "RUF", # ruff: enforce ruff specific rules + "S", # flake8-bandit: check for security issues + "SLF", # flake8-self: prevent accessing private class member + "TC", # flake8-type-checking: enforce best practices for type checking imports + "TD", # flake8-todos: check issues on TODO comment syntax + "UP", # pyupgrade: upgrade syntax for newer versions of Python + "W", # pycodestyle warnings: check for PEP 8 style convention warnings +] [tool.ruff.lint.extend-per-file-ignores] "examples/*.py" = ["D"] diff --git a/src/linkup/__init__.py b/src/linkup/__init__.py index 81fbe0a..c6db54e 100644 --- a/src/linkup/__init__.py +++ b/src/linkup/__init__.py @@ -1,6 +1,5 @@ -from ._version import __version__ -from .client import LinkupClient -from .errors import ( +from ._client import LinkupClient +from ._errors import ( LinkupAuthenticationError, LinkupFailedFetchError, LinkupInsufficientCreditError, @@ -9,7 +8,7 @@ LinkupTooManyRequestsError, LinkupUnknownError, ) -from .types import ( +from ._types import ( LinkupFetchResponse, LinkupSearchImageResult, LinkupSearchResults, @@ -18,22 +17,23 @@ LinkupSource, LinkupSourcedAnswer, ) +from ._version import __version__ __all__ = [ - "__version__", - "LinkupClient", "LinkupAuthenticationError", + "LinkupClient", "LinkupFailedFetchError", + "LinkupFetchResponse", "LinkupInsufficientCreditError", "LinkupInvalidRequestError", "LinkupNoResultError", - "LinkupTooManyRequestsError", - "LinkupUnknownError", - "LinkupFetchResponse", "LinkupSearchImageResult", "LinkupSearchResults", "LinkupSearchStructuredResponse", "LinkupSearchTextResult", "LinkupSource", "LinkupSourcedAnswer", + "LinkupTooManyRequestsError", + "LinkupUnknownError", + "__version__", ] diff --git a/src/linkup/client.py b/src/linkup/_client.py similarity index 91% rename from src/linkup/client.py rename to src/linkup/_client.py index 8779f23..638c691 100644 --- a/src/linkup/client.py +++ b/src/linkup/_client.py @@ -6,10 +6,9 @@ from typing import Any, Literal, Optional, Union import httpx -from pydantic import BaseModel +from pydantic import BaseModel, SecretStr -from linkup._version import __version__ -from linkup.errors import ( +from ._errors import ( LinkupAuthenticationError, LinkupFailedFetchError, LinkupInsufficientCreditError, @@ -18,12 +17,13 @@ LinkupTooManyRequestsError, LinkupUnknownError, ) -from linkup.types import ( +from ._types import ( LinkupFetchResponse, LinkupSearchResults, LinkupSearchStructuredResponse, LinkupSourcedAnswer, ) +from ._version import __version__ class LinkupClient: @@ -42,16 +42,18 @@ class LinkupClient: def __init__( self, - api_key: Optional[str] = None, + api_key: Union[str, SecretStr, None] = None, base_url: str = "https://api.linkup.so/v1", ) -> None: if api_key is None: api_key = os.getenv("LINKUP_API_KEY") if not api_key: raise ValueError("The Linkup API key was not provided") + if isinstance(api_key, str): + api_key = SecretStr(api_key) - self.__api_key = api_key - self.__base_url = base_url + self._api_key: SecretStr = api_key + self._base_url: str = base_url def search( self, @@ -332,7 +334,7 @@ def _user_agent(self) -> str: # pragma: no cover def _headers(self) -> dict[str, str]: # pragma: no cover return { - "Authorization": f"Bearer {self.__api_key}", + "Authorization": f"Bearer {self._api_key.get_secret_value()}", "User-Agent": self._user_agent(), } @@ -342,7 +344,7 @@ def _request( url: str, **kwargs: Any, ) -> httpx.Response: # pragma: no cover - with httpx.Client(base_url=self.__base_url, headers=self._headers()) as client: + with httpx.Client(base_url=self._base_url, headers=self._headers()) as client: return client.request( method=method, url=url, @@ -355,7 +357,7 @@ async def _async_request( url: str, **kwargs: Any, ) -> httpx.Response: # pragma: no cover - async with httpx.AsyncClient(base_url=self.__base_url, headers=self._headers()) as client: + async with httpx.AsyncClient(base_url=self._base_url, headers=self._headers()) as client: return await client.request( method=method, url=url, @@ -391,52 +393,49 @@ def _raise_linkup_error(self, response: httpx.Response) -> None: "The provided URL might not be found or can't be fetched.\n" f"Original error message: {error_msg}." ) - else: - raise LinkupInvalidRequestError( - "The Linkup API returned an invalid request error (400). Make sure the " - "parameters you used are valid (correct values, types, mandatory " - "parameters, etc.) and you are using the latest version of the Python " - "SDK.\n" - f"Original error message: {error_msg}." - ) - elif response.status_code == 401: + raise LinkupInvalidRequestError( + "The Linkup API returned an invalid request error (400). Make sure the " + "parameters you used are valid (correct values, types, mandatory " + "parameters, etc.) and you are using the latest version of the Python " + "SDK.\n" + f"Original error message: {error_msg}." + ) + if response.status_code == 401: raise LinkupAuthenticationError( "The Linkup API returned an authentication error (401). Make sure your API " "key is valid.\n" f"Original error message: {error_msg}." ) - elif response.status_code == 403: + if response.status_code == 403: raise LinkupAuthenticationError( "The Linkup API returned an authorization error (403). Make sure your API " "key is valid.\n" f"Original error message: {error_msg}." ) - elif response.status_code == 429: + if response.status_code == 429: if code == "INSUFFICIENT_FUNDS_CREDITS": raise LinkupInsufficientCreditError( "The Linkup API returned an insufficient credit error (429). Make sure " "you haven't exhausted your credits.\n" f"Original error message: {error_msg}." ) - elif code == "TOO_MANY_REQUESTS": + if code == "TOO_MANY_REQUESTS": raise LinkupTooManyRequestsError( "The Linkup API returned a too many requests error (429). Make sure " "you not sending too many requests at a time.\n" f"Original error message: {error_msg}." ) - else: - raise LinkupUnknownError( - "The Linkup API returned an invalid request error (429). Make sure the " - "parameters you used are valid (correct values, types, mandatory " - "parameters, etc.) and you are using the latest version of the Python " - "SDK.\n" - f"Original error message: {error_msg}." - ) - else: raise LinkupUnknownError( - f"The Linkup API returned an unknown error ({response.status_code}).\n" - f"Original error message: ({error_msg})." + "The Linkup API returned an invalid request error (429). Make sure the " + "parameters you used are valid (correct values, types, mandatory " + "parameters, etc.) and you are using the latest version of the Python " + "SDK.\n" + f"Original error message: {error_msg}." ) + raise LinkupUnknownError( + f"The Linkup API returned an unknown error ({response.status_code}).\n" + f"Original error message: ({error_msg})." + ) def _get_search_params( self, @@ -452,11 +451,11 @@ def _get_search_params( include_inline_citations: Optional[bool], include_sources: Optional[bool], ) -> dict[str, Union[str, bool, list[str]]]: - params: dict[str, Union[str, bool, list[str]]] = dict( - q=query, - depth=depth, - outputType=output_type, - ) + params: dict[str, Union[str, bool, list[str]]] = { + "q": query, + "depth": depth, + "outputType": output_type, + } if structured_output_schema is not None: if isinstance(structured_output_schema, str): @@ -513,9 +512,9 @@ def _parse_search_response( response_data: Any = response.json() if output_type == "searchResults": return LinkupSearchResults.model_validate(response_data) - elif output_type == "sourcedAnswer": + if output_type == "sourcedAnswer": return LinkupSourcedAnswer.model_validate(response_data) - elif output_type == "structured": + if output_type == "structured": if structured_output_schema is None: raise ValueError( "structured_output_schema must be provided when output_type is 'structured'" @@ -535,8 +534,7 @@ def _parse_search_response( ): return structured_output_schema.model_validate(response_data) return response_data - else: - raise ValueError(f"Unexpected output_type value: '{output_type}'") + raise ValueError(f"Unexpected output_type value: '{output_type}'") def _parse_fetch_response(self, response: httpx.Response) -> LinkupFetchResponse: return LinkupFetchResponse.model_validate(response.json()) diff --git a/src/linkup/errors.py b/src/linkup/_errors.py similarity index 100% rename from src/linkup/errors.py rename to src/linkup/_errors.py diff --git a/src/linkup/types.py b/src/linkup/_types.py similarity index 100% rename from src/linkup/types.py rename to src/linkup/_types.py diff --git a/tests/unit/client_test.py b/tests/unit/client_test.py index b6186b5..98c19eb 100644 --- a/tests/unit/client_test.py +++ b/tests/unit/client_test.py @@ -10,23 +10,19 @@ from linkup import ( LinkupAuthenticationError, LinkupClient, - LinkupInvalidRequestError, - LinkupSearchResults, - LinkupSource, - LinkupSourcedAnswer, - LinkupUnknownError, -) -from linkup.errors import ( LinkupFailedFetchError, + LinkupFetchResponse, LinkupInsufficientCreditError, + LinkupInvalidRequestError, LinkupNoResultError, - LinkupTooManyRequestsError, -) -from linkup.types import ( - LinkupFetchResponse, LinkupSearchImageResult, + LinkupSearchResults, LinkupSearchStructuredResponse, LinkupSearchTextResult, + LinkupSource, + LinkupSourcedAnswer, + LinkupTooManyRequestsError, + LinkupUnknownError, ) @@ -179,12 +175,12 @@ class Company(BaseModel): "website_url": "https://www.linkup.so/" } """, - dict( - name="Linkup", - founders_names=["Philippe Mizrahi", "Denis Charrier", "Boris Toledano"], - creation_date="2024", - website_url="https://www.linkup.so/", - ), + { + "name": "Linkup", + "founders_names": ["Philippe Mizrahi", "Denis Charrier", "Boris Toledano"], + "creation_date": "2024", + "website_url": "https://www.linkup.so/", + }, ), ( { @@ -273,12 +269,12 @@ class Company(BaseModel): } """, LinkupSearchStructuredResponse( - data=dict( - name="Linkup", - founders_names=["Philippe Mizrahi", "Denis Charrier", "Boris Toledano"], - creation_date="2024", - website_url="https://www.linkup.so/", - ), + data={ + "name": "Linkup", + "founders_names": ["Philippe Mizrahi", "Denis Charrier", "Boris Toledano"], + "creation_date": "2024", + "website_url": "https://www.linkup.so/", + }, sources=[ LinkupSearchTextResult( type="text", @@ -306,9 +302,9 @@ def test_search( mock_request_response_content: bytes, expected_search_response: Any, ) -> None: - mocker.patch("linkup.client.date").today.return_value = date(2000, 1, 1) + mocker.patch("linkup._client.date").today.return_value = date(2000, 1, 1) request_mock = mocker.patch( - "linkup.client.LinkupClient._request", + "linkup._client.LinkupClient._request", return_value=Response( status_code=200, content=mock_request_response_content, @@ -339,9 +335,9 @@ async def test_async_search( mock_request_response_content: bytes, expected_search_response: Any, ) -> None: - mocker.patch("linkup.client.date").today.return_value = date(2000, 1, 1) + mocker.patch("linkup._client.date").today.return_value = date(2000, 1, 1) request_mock = mocker.patch( - "linkup.client.LinkupClient._async_request", + "linkup._client.LinkupClient._async_request", return_value=Response( status_code=200, content=mock_request_response_content, @@ -483,7 +479,7 @@ def test_search_error( expected_exception: Any, ) -> None: request_mock = mocker.patch( - "linkup.client.LinkupClient._request", + "linkup._client.LinkupClient._request", return_value=Response( status_code=mock_request_response_status_code, content=mock_request_response_content, @@ -508,7 +504,7 @@ async def test_async_search_error( expected_exception: Any, ) -> None: request_mock = mocker.patch( - "linkup.client.LinkupClient._async_request", + "linkup._client.LinkupClient._async_request", return_value=Response( status_code=mock_request_response_status_code, content=mock_request_response_content, @@ -549,7 +545,7 @@ def test_fetch( expected_fetch_response: Any, ) -> None: request_mock = mocker.patch( - "linkup.client.LinkupClient._request", + "linkup._client.LinkupClient._request", return_value=Response( status_code=200, content=mock_request_response_content, @@ -580,7 +576,7 @@ async def test_async_fetch( expected_fetch_response: Any, ) -> None: request_mock = mocker.patch( - "linkup.client.LinkupClient._async_request", + "linkup._client.LinkupClient._async_request", return_value=Response( status_code=200, content=mock_request_response_content, @@ -644,7 +640,7 @@ def test_fetch_error( expected_exception: Any, ) -> None: request_mock = mocker.patch( - "linkup.client.LinkupClient._request", + "linkup._client.LinkupClient._request", return_value=Response( status_code=mock_request_response_status_code, content=mock_request_response_content, @@ -669,7 +665,7 @@ async def test_async_fetch_error( expected_exception: Any, ) -> None: request_mock = mocker.patch( - "linkup.client.LinkupClient._async_request", + "linkup._client.LinkupClient._async_request", return_value=Response( status_code=mock_request_response_status_code, content=mock_request_response_content,