Skip to content

Commit f11114e

Browse files
committed
Merge remote-tracking branch 'origin/master' into add-support-for-markdown-output
2 parents 1d03491 + 0830a5b commit f11114e

11 files changed

Lines changed: 306 additions & 30 deletions

File tree

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ jobs:
1515
runs-on: ubuntu-latest
1616
strategy:
1717
matrix:
18-
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
18+
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
1919

2020
steps:
2121
- name: Checkout repository
@@ -36,7 +36,7 @@ jobs:
3636
API_KEY: ${{ secrets.API_KEY }}
3737

3838
- name: Run example tests
39-
if: matrix.python-version == '3.13'
39+
if: matrix.python-version == '3.14'
4040
run: pytest -k "example"
4141
env:
4242
API_KEY: ${{ secrets.API_KEY }}

.github/workflows/release.yml

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ name: Release
33
on:
44
push:
55
tags: ['v**']
6+
workflow_dispatch:
7+
inputs:
8+
allow_example_test_failures:
9+
description: 'Publish even if the live example tests fail (use only when a SerpApi engine is down)'
10+
type: boolean
11+
default: false
612

713
concurrency:
814
group: ${{ github.workflow }}-${{ github.ref }}
@@ -14,10 +20,16 @@ jobs:
1420
runs-on: ubuntu-latest
1521
strategy:
1622
matrix:
17-
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
23+
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
1824
steps:
1925
- uses: actions/checkout@v6
2026

27+
- name: Ensure ref is a tag
28+
if: github.ref_type != 'tag'
29+
run: |
30+
echo "Release must run against a tag, got '${{ github.ref }}'" >&2
31+
exit 1
32+
2133
- uses: actions/setup-python@v6
2234
with:
2335
python-version: ${{ matrix.python-version }}
@@ -31,7 +43,8 @@ jobs:
3143
API_KEY: ${{ secrets.API_KEY }}
3244

3345
- name: Run example tests
34-
if: matrix.python-version == '3.13'
46+
if: matrix.python-version == '3.14'
47+
continue-on-error: ${{ inputs.allow_example_test_failures == true }}
3548
run: pytest -k "example"
3649
env:
3750
API_KEY: ${{ secrets.API_KEY }}
@@ -62,19 +75,18 @@ jobs:
6275
name: Publish release
6376
needs: [build]
6477
runs-on: ubuntu-latest
78+
environment:
79+
name: pypi
80+
url: https://pypi.org/p/serpapi
6581
permissions:
6682
contents: write
67-
packages: write
83+
id-token: write
6884
steps:
6985
- uses: actions/download-artifact@v8
7086
with:
7187
name: dist
7288
path: dist/
7389

74-
- uses: actions/setup-python@v6
75-
with:
76-
python-version: "3.13"
77-
7890
- name: Create GitHub Release
7991
env:
8092
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -83,14 +95,8 @@ jobs:
8395
gh release create ${{ github.ref_name }} dist/* --generate-notes \
8496
|| gh release upload ${{ github.ref_name }} dist/* --clobber
8597
86-
- name: Install twine
87-
run: pip install --upgrade pip twine
88-
8998
- name: Publish to PyPI
90-
env:
91-
TWINE_USERNAME: __token__
92-
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
93-
run: twine upload dist/*
99+
uses: pypa/gh-action-pypi-publish@release/v1
94100

95101
smoke-test:
96102
name: Smoke test published package

README.md

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,38 @@ results = client.search({
297297
```
298298
- API Documentation: [serpapi.com/google-reverse-image](https://serpapi.com/google-reverse-image)
299299

300+
### Search Google Lens by image URL or upload
301+
302+
Google Lens accepts either a publicly accessible image URL or an uploaded
303+
image. To search by URL, pass the URL directly:
304+
305+
```python
306+
import os
307+
import serpapi
308+
309+
client = serpapi.Client(api_key=os.getenv("SERPAPI_KEY"))
310+
results = client.search({
311+
"engine": "google_lens",
312+
"url": "https://i.imgur.com/HBrB8p0.png",
313+
})
314+
```
315+
316+
To search a local image, upload it first and pass its temporary `image_id` to
317+
Google Lens:
318+
319+
```python
320+
upload = client.upload_image("/path/to/image.png")
321+
results = client.search({
322+
"engine": "google_lens",
323+
"image_id": upload["image_id"],
324+
})
325+
```
326+
327+
Uploaded images can be JPG/JPEG, PNG, or WebP files up to 500 KB. The returned
328+
`image_id` expires after 10 minutes.
329+
330+
- API Documentation: [Google Lens image uploads](https://serpapi.com/google-lens-upload-an-image), [Image API](https://serpapi.com/image-api)
331+
300332
### Search Google Events
301333
```python
302334
import os
@@ -400,4 +432,4 @@ Bug reports and pull requests are welcome on GitHub. Once dependencies are insta
400432
```
401433
This triggers the [release workflow](.github/workflows/release.yml), which tests, builds, and publishes to PyPI, then smoke-tests the published package.
402434

403-
> **Required secrets:** `PYPI_API_TOKEN` (PyPI upload token) and `API_KEY` (used in smoke-test live search).
435+
> **Required secret:** `API_KEY` (used in smoke-test live search).

docs/index.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ This part of the documentation covers all the interfaces of :class:`serpapi` Pyt
8686

8787
.. autofunction:: serpapi.search
8888
.. autofunction:: serpapi.search_archive
89+
.. autofunction:: serpapi.upload_image
8990
.. autofunction:: serpapi.locations
9091
.. autofunction:: serpapi.account
9192

@@ -159,6 +160,7 @@ This class also alleviates the need to pass an ``api_key``` along with every se
159160

160161
.. automethod:: Client.search
161162
.. automethod:: Client.search_archive
163+
.. automethod:: Client.upload_image
162164
.. automethod:: Client.account
163165
.. automethod:: Client.locations
164166

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ classifiers = [
2828
"Programming Language :: Python :: 3.11",
2929
"Programming Language :: Python :: 3.12",
3030
"Programming Language :: Python :: 3.13",
31+
"Programming Language :: Python :: 3.14",
3132
"Programming Language :: Python :: Implementation :: CPython",
3233
"Natural Language :: English",
3334
"Topic :: Utilities",

serpapi/__version__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "1.0.2"
1+
__version__ = "1.1.0"

serpapi/core.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import io
2+
import os
3+
14
from .http import HTTPClient
25
from .exceptions import SearchIDNotProvided
36
from .models import SerpResults
@@ -108,6 +111,53 @@ def search_archive(self, params: dict = None, **kwargs):
108111
r = self.request("GET", f"/searches/{ search_id }", params=params, **request_kwargs)
109112
return SerpResults.from_http_response(r, client=self)
110113

114+
def upload_image(self, image, **kwargs):
115+
"""Upload an image to SerpApi's Image API.
116+
117+
``image`` can be a filesystem path or an open binary file object. The
118+
returned dictionary contains an ``image_id`` that can be passed to
119+
:meth:`search` for engines that accept uploaded images, such as Google
120+
Lens.
121+
122+
:param image: a path or open binary file object containing a JPG/JPEG,
123+
PNG, or WebP image no larger than 500 KB.
124+
:param api_key: the API Key to use for SerpApi.com.
125+
:param **: any additional multipart form fields to pass to the API.
126+
127+
**Learn more**: https://serpapi.com/image-api
128+
"""
129+
request_kwargs = {}
130+
for key in ["timeout", "proxies", "verify", "stream", "cert"]:
131+
if key in kwargs:
132+
request_kwargs[key] = kwargs.pop(key)
133+
134+
data = kwargs
135+
if "api_key" not in data:
136+
data["api_key"] = self.api_key
137+
138+
image_file = None
139+
try:
140+
if isinstance(image, (str, os.PathLike)):
141+
image_file = open(image, "rb")
142+
image = image_file
143+
elif isinstance(image, io.TextIOBase):
144+
raise TypeError(
145+
"image file must be opened in binary mode, e.g. open(path, 'rb')"
146+
)
147+
148+
r = self.request(
149+
"POST",
150+
"/image",
151+
params={},
152+
data=data,
153+
files={"image": image},
154+
**request_kwargs,
155+
)
156+
return r.json()
157+
finally:
158+
if image_file is not None:
159+
image_file.close()
160+
111161
def locations(self, params: dict = None, **kwargs):
112162
"""Get a list of supported Google locations.
113163
@@ -168,5 +218,6 @@ def account(self, params: dict = None, **kwargs):
168218
_client = Client()
169219
search = _client.search
170220
search_archive = _client.search_archive
221+
upload_image = _client.upload_image
171222
locations = _client.locations
172223
account = _client.account

serpapi/http.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ def __init__(self, *, api_key=None, timeout=None):
2323

2424
def request(self, method, path, params, *, assert_200=True, **kwargs):
2525
# Inject the API Key into the params.
26-
if "api_key" not in params:
26+
request_data = kwargs.get("data")
27+
api_key_in_data = isinstance(request_data, dict) and "api_key" in request_data
28+
if "api_key" not in params and not api_key_in_data:
2729
params["api_key"] = self.api_key
2830

2931
# Build the URL, as needed.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Example: google_light search engine
2+
import pytest
3+
import os
4+
import serpapi
5+
6+
def test_search_google_light(client):
7+
data = client.search({
8+
'engine': 'google_light',
9+
'q': 'coffee',
10+
})
11+
assert data.get('error') is None
12+
assert data['organic_results']

tests/test_image_upload.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
from io import BytesIO, StringIO
2+
from unittest.mock import Mock
3+
4+
import pytest
5+
import requests
6+
7+
import serpapi
8+
9+
10+
def json_response(data):
11+
response = requests.Response()
12+
response.status_code = 200
13+
response._content = data
14+
return response
15+
16+
17+
def test_upload_image_path_sends_multipart_request(tmp_path):
18+
image_path = tmp_path / "test.png"
19+
image_path.write_bytes(b"fake-png-data")
20+
client = serpapi.Client(api_key="test-api-key")
21+
22+
def request(**kwargs):
23+
assert kwargs["method"] == "POST"
24+
assert kwargs["url"] == "https://serpapi.com/image"
25+
assert kwargs["params"] == {}
26+
assert kwargs["data"] == {"api_key": "test-api-key"}
27+
assert kwargs["files"]["image"].name == str(image_path)
28+
assert kwargs["files"]["image"].read() == b"fake-png-data"
29+
return json_response(
30+
b'{"message": "Image uploaded successfully.", "image_id": "image-123"}'
31+
)
32+
33+
client.session.request = Mock(side_effect=request)
34+
35+
result = client.upload_image(image_path)
36+
37+
assert result["image_id"] == "image-123"
38+
39+
40+
def test_upload_image_accepts_open_binary_file_and_request_options():
41+
image = BytesIO(b"fake-image-data")
42+
client = serpapi.Client(api_key="client-api-key", timeout=10)
43+
client.session.request = Mock(
44+
return_value=json_response(b'{"image_id": "image-456"}')
45+
)
46+
47+
result = client.upload_image(
48+
image,
49+
api_key="request-api-key",
50+
timeout=5,
51+
zero_trace="true",
52+
)
53+
54+
assert result == {"image_id": "image-456"}
55+
assert not image.closed
56+
_, request_kwargs = client.session.request.call_args
57+
assert request_kwargs["params"] == {}
58+
assert request_kwargs["data"] == {
59+
"api_key": "request-api-key",
60+
"zero_trace": "true",
61+
}
62+
assert request_kwargs["files"] == {"image": image}
63+
assert request_kwargs["timeout"] == 5
64+
65+
66+
def test_upload_image_rejects_text_mode_file(tmp_path):
67+
image_path = tmp_path / "test.png"
68+
image_path.write_text("not binary image data")
69+
client = serpapi.Client(api_key="test-api-key")
70+
client.session.request = Mock()
71+
72+
with image_path.open("r") as image:
73+
with pytest.raises(TypeError, match="opened in binary mode"):
74+
client.upload_image(image)
75+
76+
client.session.request.assert_not_called()
77+
78+
79+
def test_upload_image_rejects_string_io():
80+
client = serpapi.Client(api_key="test-api-key")
81+
client.session.request = Mock()
82+
83+
with pytest.raises(TypeError, match="opened in binary mode"):
84+
client.upload_image(StringIO("not binary image data"))
85+
86+
client.session.request.assert_not_called()
87+
88+
89+
def test_request_injects_api_key_when_form_data_does_not_include_it():
90+
client = serpapi.Client(api_key="test-api-key")
91+
client.session.request = Mock(return_value=json_response(b"{}"))
92+
93+
client.request("POST", "/example", params={}, data={"field": "value"})
94+
95+
_, request_kwargs = client.session.request.call_args
96+
assert request_kwargs["params"] == {"api_key": "test-api-key"}
97+
assert request_kwargs["data"] == {"field": "value"}
98+
99+
100+
def test_module_exposes_upload_image_entrypoint():
101+
assert callable(serpapi.upload_image)

0 commit comments

Comments
 (0)