diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..11c169e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,24 @@ +name: Semantic Release + +on: + push: + branches: + - main + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v4 + with: + python-version: "3.10" + + - name: Install dependencies + run: | + pip install -U pip + pip install python-semantic-release + - name: Run semantic-release + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + run: semantic-release publish diff --git a/Makefile b/Makefile index f438f95..681bfae 100644 --- a/Makefile +++ b/Makefile @@ -11,3 +11,6 @@ test: 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 +release: + @echo "Running semantic release..." + semantic-release publish diff --git a/pyproject.toml b/pyproject.toml index 847d800..5ca12ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,3 +70,18 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/linkup"] + +[tool.semantic_release] +version_source = "file" +version_pattern = "pyproject.toml:version = \"{version}\"" +commit_parser = "conventional" +tag_format = "v{version}" +commit_message = "{version}\n\nAutomatically generated by python-semantic-release" +major_on_zero = true + +[tool.semantic_release.changelog.default_templates] +changelog_file = "CHANGELOG.md" + +[tool.semantic_release.commit_parser_options] +minor_tags = ["feat"] +patch_tags = ["fix", "perf"] diff --git a/src/linkup/__init__.py b/src/linkup/__init__.py index 22dd0b3..bb43ac7 100644 --- a/src/linkup/__init__.py +++ b/src/linkup/__init__.py @@ -15,6 +15,7 @@ LinkupSearchTextResult, LinkupSource, LinkupSourcedAnswer, + LinkupStructuredResult, ) __all__ = [ @@ -30,4 +31,5 @@ "LinkupSearchResults", "LinkupSource", "LinkupSourcedAnswer", + "LinkupStructuredResult", ] diff --git a/src/linkup/client.py b/src/linkup/client.py index 7aaacab..58aa6ff 100644 --- a/src/linkup/client.py +++ b/src/linkup/client.py @@ -15,7 +15,7 @@ LinkupTooManyRequestsError, LinkupUnknownError, ) -from linkup.types import LinkupSearchResults, LinkupSourcedAnswer +from linkup.types import LinkupSearchResults, LinkupSourcedAnswer, LinkupStructuredResult class LinkupClient: @@ -84,9 +84,8 @@ def search( Returns: The Linkup API search result. If output_type is "searchResults", the result will be a linkup.LinkupSearchResults. If output_type is "sourcedAnswer", the result will be a - linkup.LinkupSourcedAnswer. If output_type is "structured", the result will be - either an instance of the provided pydantic.BaseModel, or an arbitrary data - structure, following structured_output_schema. + linkup.LinkupSourcedAnswer. If output_type is "structured", + the result will be a linkup.LinkupStructuredResult Raises: TypeError: If structured_output_schema is not provided or is not a string or a @@ -162,9 +161,9 @@ async def async_search( Returns: The Linkup API search result. If output_type is "searchResults", the result will be a linkup.LinkupSearchResults. If output_type is "sourcedAnswer", the result will be a - linkup.LinkupSourcedAnswer. If output_type is "structured", the result will be - either an instance of the provided pydantic.BaseModel, or an arbitrary data - structure, following structured_output_schema. + + linkup.LinkupSourcedAnswer. If output_type is "structured", + the result will be a linkup.LinkupStructuredResult Raises: TypeError: If structured_output_schema is not provided or is not a string or a @@ -359,12 +358,8 @@ def _validate_search_response( output_base_model = LinkupSearchResults elif output_type == "sourcedAnswer": output_base_model = LinkupSourcedAnswer - elif ( - output_type == "structured" - and not isinstance(structured_output_schema, (str, type(None))) - and issubclass(structured_output_schema, BaseModel) - ): - output_base_model = structured_output_schema + elif output_type == "structured": + output_base_model = LinkupStructuredResult if output_base_model is None: return response_data diff --git a/src/linkup/types.py b/src/linkup/types.py index 4e7d7d6..1c25858 100644 --- a/src/linkup/types.py +++ b/src/linkup/types.py @@ -1,4 +1,4 @@ -from typing import List, Union +from typing import Any, Dict, List, Union from pydantic import BaseModel @@ -61,6 +61,19 @@ class LinkupSource(BaseModel): snippet: str +class LinkupStructuredResult(BaseModel): + """ + A Linkup answer in structured, with the sources supporting it + + Attributes: + data: The answer that strictly follows the structuredOutput + sources: The sources supporting the answer. + """ + + data: Dict[str, Any] + sources: List[Union[LinkupSource, LinkupSearchTextResult, LinkupSearchImageResult]] + + class LinkupSourcedAnswer(BaseModel): """ A Linkup answer, with the sources supporting it. diff --git a/tests/unit_tests/client_test.py b/tests/unit_tests/client_test.py index a74b48d..182e154 100644 --- a/tests/unit_tests/client_test.py +++ b/tests/unit_tests/client_test.py @@ -13,6 +13,7 @@ LinkupSearchResults, LinkupSource, LinkupSourcedAnswer, + LinkupStructuredResult, LinkupUnknownError, ) from linkup.errors import ( @@ -136,13 +137,22 @@ def test_search_structured_search( depth = "standard" output_type = "structured" + content = b""" + { + "data": { + "name": "Linkup", + "creation_date": "2024", + "website_url": "", + "founders_names": ["Philippe Mizrahi", "Denis Charrier", "Boris Toledano"], + "title": "Company" + }, + "sources": [{"name": "foo", "url": "https://foo.bar/baz", "snippet": "foo bar baz"}] + } + """ + mocker.patch( "linkup.client.LinkupClient._request", - return_value=Response( - status_code=200, - content=b'{"name":"Linkup","founders_names":["Philippe Mizrahi","Denis Charrier",' - b'"Boris Toledano"],"creation_date":"2024","website_url":"","title":"Company"}', - ), + return_value=Response(status_code=200, content=content), ) response: Any = client.search( @@ -152,21 +162,12 @@ def test_search_structured_search( structured_output_schema=structured_output_schema, ) - if isinstance(structured_output_schema, str): - assert response == dict( - creation_date="2024", - founders_names=["Philippe Mizrahi", "Denis Charrier", "Boris Toledano"], - name="Linkup", - title="Company", - website_url="", - ) - - else: - assert isinstance(response, Company) - assert response.name == "Linkup" - assert response.creation_date == "2024" - assert response.website_url == "" - assert response.founders_names == ["Philippe Mizrahi", "Denis Charrier", "Boris Toledano"] + assert isinstance(response, LinkupStructuredResult) + assert isinstance(response.data, dict) + assert isinstance(response.sources[0], LinkupSource) + assert response.sources[0].name == "foo" + assert response.sources[0].url == "https://foo.bar/baz" + assert response.sources[0].snippet == "foo bar baz" def test_search_authorization_error(mocker: MockerFixture, client: LinkupClient) -> None: @@ -491,12 +492,23 @@ async def test_async_search_structured_search( depth = "standard" output_type = "structured" - mocker.patch( - "linkup.client.LinkupClient._async_request", - return_value=Response( - status_code=200, - content=b'{"name":"Linkup","founders_names":["Philippe Mizrahi","Denis Charrier",' - b'"Boris Toledano"],"creation_date":"2024","website_url":"","title":"Company"}', + content = b""" + { + "data": { + "name": "Linkup", + "creation_date": "2024", + "website_url": "", + "founders_names": ["Philippe Mizrahi", "Denis Charrier", "Boris Toledano"], + "title": "Company" + }, + "sources": [{"name": "foo", "url": "https://foo.bar/baz", "snippet": "foo bar baz"}] + } + """ + + ( + mocker.patch( + "linkup.client.LinkupClient._async_request", + return_value=Response(status_code=200, content=content), ), ) @@ -507,21 +519,12 @@ async def test_async_search_structured_search( structured_output_schema=structured_output_schema, ) - if isinstance(structured_output_schema, str): - assert response == dict( - creation_date="2024", - founders_names=["Philippe Mizrahi", "Denis Charrier", "Boris Toledano"], - name="Linkup", - title="Company", - website_url="", - ) - - else: - assert isinstance(response, Company) - assert response.name == "Linkup" - assert response.creation_date == "2024" - assert response.website_url == "" - assert response.founders_names == ["Philippe Mizrahi", "Denis Charrier", "Boris Toledano"] + assert isinstance(response, LinkupStructuredResult) + assert isinstance(response.data, dict) + assert isinstance(response.sources[0], LinkupSource) + assert response.sources[0].name == "foo" + assert response.sources[0].url == "https://foo.bar/baz" + assert response.sources[0].snippet == "foo bar baz" @pytest.mark.asyncio