Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
15 changes: 15 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
2 changes: 2 additions & 0 deletions src/linkup/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
LinkupSearchTextResult,
LinkupSource,
LinkupSourcedAnswer,
LinkupStructuredResult,
)

__all__ = [
Expand All @@ -30,4 +31,5 @@
"LinkupSearchResults",
"LinkupSource",
"LinkupSourcedAnswer",
"LinkupStructuredResult",
]
21 changes: 8 additions & 13 deletions src/linkup/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
LinkupTooManyRequestsError,
LinkupUnknownError,
)
from linkup.types import LinkupSearchResults, LinkupSourcedAnswer
from linkup.types import LinkupSearchResults, LinkupSourcedAnswer, LinkupStructuredResult


class LinkupClient:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
15 changes: 14 additions & 1 deletion src/linkup/types.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import List, Union
from typing import Any, Dict, List, Union

from pydantic import BaseModel

Expand Down Expand Up @@ -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.
Expand Down
85 changes: 44 additions & 41 deletions tests/unit_tests/client_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
LinkupSearchResults,
LinkupSource,
LinkupSourcedAnswer,
LinkupStructuredResult,
LinkupUnknownError,
)
from linkup.errors import (
Expand Down Expand Up @@ -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(
Expand All @@ -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:
Expand Down Expand Up @@ -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),
),
)

Expand All @@ -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
Expand Down