diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f220fd..4afc87d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] steps: - name: Checkout repository diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c3c5335..b242d52 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v6 diff --git a/pyproject.toml b/pyproject.toml index 0df45a5..d345b89 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: CPython", "Natural Language :: English", "Topic :: Utilities", diff --git a/tests/example_search_google_light_test.py b/tests/example_search_google_light_test.py new file mode 100644 index 0000000..3213643 --- /dev/null +++ b/tests/example_search_google_light_test.py @@ -0,0 +1,12 @@ +# Example: google_light search engine +import pytest +import os +import serpapi + +def test_search_google_light(client): + data = client.search({ + 'engine': 'google_light', + 'q': 'coffee', + }) + assert data.get('error') is None + assert data['organic_results'] diff --git a/tests/test_integration.py b/tests/test_integration.py index 0ec9f78..d2f59a4 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,8 +1,16 @@ +from urllib.parse import parse_qs, urlparse + import pytest import serpapi +def _query_param(url, param): + values = parse_qs(urlparse(url).query).get(param) + if values: + return values[0] + + def test_basic_import(): """Test that basic import works as intended.""" import serpapi @@ -18,6 +26,43 @@ def test_entrypoints(client): assert api.locations +def test_locations_query_limit_and_shape(client): + locations = client.locations(q="Austin", limit=5) + + assert isinstance(locations, list) + assert 0 < len(locations) <= 5 + + expected_fields = { + "id", + "name", + "canonical_name", + "country_code", + "target_type", + "reach", + } + + for location in locations: + assert expected_fields.issubset(location.keys()) + assert isinstance(location["canonical_name"], str) + + assert any( + "Austin" in location["name"] or "Austin" in location["canonical_name"] + for location in locations + ) + + +def test_search_accepts_location_from_locations_api(client): + locations = client.locations(q="Austin", limit=1) + location_name = locations[0]["canonical_name"] + + search = client.search(q="coffee", location=location_name) + + assert search.get("error") is None + assert search["organic_results"] + assert "Austin" in search["search_parameters"]["location_requested"] + assert "United States" in search["search_parameters"]["location_used"] + + def test_account_without_credentials(): """Ensure that an HTTPError is raised when account is accessed without API Credentials.""" with pytest.raises(serpapi.HTTPError): @@ -58,29 +103,53 @@ def test_coffee_search_as_dict(coffee_search): assert isinstance(d, dict) -def test_coffee_search_html(coffee_search_html): +def test_search_output_html_contains_raw_html_document(coffee_search_html): assert isinstance(coffee_search_html, str) + assert "" in coffee_search_html.lower() + assert "coffee" in coffee_search_html.lower() assert not hasattr(coffee_search_html, "next_page_url") -def test_coffee_search_n_pages(coffee_search): - page_count = 0 - max_pages = 3 +def test_next_page_url_uses_serpapi_pagination_next(coffee_search): + next_page_url = coffee_search.next_page_url - for page in coffee_search.yield_pages(max_pages=max_pages): - if page_count == 0: - assert 'start' not in page['search_parameters'], "The 'start' parameter should not be in the first page" - - page_count += 1 + assert next_page_url == coffee_search["serpapi_pagination"]["next"] + assert _query_param(next_page_url, "start") == "10" + assert _query_param(next_page_url, "api_key") is None + + +def test_yield_pages_returns_unique_search_pages(coffee_search): + max_pages = 3 + pages = list(coffee_search.yield_pages(max_pages=max_pages)) - assert page_count == max_pages + assert len(pages) == max_pages + assert len({page["search_metadata"]["id"] for page in pages}) == max_pages + assert "start" not in pages[0]["search_parameters"] + assert int(pages[1]["search_parameters"]["start"]) == 10 + assert int(pages[2]["search_parameters"]["start"]) == 20 -def test_coffee_search_next_page(coffee_search): +def test_next_page_advances_start_and_returns_new_results(coffee_search): next_page = coffee_search.next_page() assert isinstance(next_page, serpapi.SerpResults) assert coffee_search["search_metadata"]["id"] != next_page["search_metadata"]["id"] + assert int(next_page["search_parameters"]["start"]) == 10 + assert next_page["organic_results"] + + page_number = next_page["search_information"].get("page_number") + if page_number is not None: + assert int(page_number) == 2 + + +def test_search_archive_round_trips_search_id(client, coffee_search): + search_id = coffee_search["search_metadata"]["id"] + + archived_search = client.search_archive(search_id=search_id) + + assert isinstance(archived_search, serpapi.SerpResults) + assert archived_search["search_metadata"]["id"] == search_id def test_search_function_signature(coffee_params, client):