Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
0692f65
x402 support: initial draft
Gallaecio Jul 23, 2025
b0be889
x402: optimize out initial requests where possible
Gallaecio Jul 23, 2025
0df1f92
Add a n_x402_req stat
Gallaecio Jul 23, 2025
2f3c9c4
Raise non-402 response as RequestError
Gallaecio Jul 23, 2025
53f9a4c
Handle import errors from x402
Gallaecio Jul 23, 2025
33dcc3e
Add x402 envs to tox and CI
Gallaecio Jul 23, 2025
8970a7f
Keep mypy happy
Gallaecio Jul 23, 2025
115abc5
Complete branch coverage
Gallaecio Jul 23, 2025
27a3b13
Use a custom retry policy for x402 initial requests
Gallaecio Jul 23, 2025
e20d1ed
Remove unneeded skip
Gallaecio Jul 23, 2025
9b42f98
Test command call with an env key
Gallaecio Jul 23, 2025
12ec3a4
Test command when an Ethereum private key is set as an env var
Gallaecio Jul 23, 2025
ad3a1bb
Keep mypy happy
Gallaecio Jul 23, 2025
cae7b64
Fix min-x402 in CI
Gallaecio Jul 23, 2025
3720c9b
Clarify required Python version
Gallaecio Jul 23, 2025
1f80f57
Let --eth-key take precedence over ZYTE_API_KEY
Gallaecio Jul 25, 2025
dec667f
Handle race conditions and unexpected server responses
Gallaecio Jul 25, 2025
a5b5e40
Improve error mapping
Gallaecio Jul 25, 2025
433dbc8
Complete test coverage, fix backward compatibility, and add client.au…
Gallaecio Jul 28, 2025
9d5115b
Expose AuthInfo
Gallaecio Jul 28, 2025
5b00725
AuthInfo.key_type → type
Gallaecio Jul 28, 2025
e3931c7
Update tests/mockserver.py
wRAR Jul 29, 2025
3557ff1
Update tests/mockserver.py
wRAR Jul 29, 2025
852fb1c
Add release notes
Gallaecio Aug 7, 2025
46a3bdd
Prepare release notes, set the right endpoint for x402
Gallaecio Aug 7, 2025
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
13 changes: 12 additions & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,22 @@ Installation

pip install zyte-api

Or, to use x402_:

.. _x402: https://www.x402.org/

.. code-block:: shell

pip install zyte-api[x402]

.. note:: Python 3.9+ is required.

.. install-end

Basic usage
===========

.. basic-start
.. basic-key-start

Set your API key
----------------
Expand All @@ -54,6 +62,9 @@ After you `sign up for a Zyte API account
<https://app.zyte.com/o/zyte-api/api-access>`_.

.. key-get-end
.. basic-key-end

.. basic-start


Use the command-line client
Expand Down
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ python-zyte-api
:maxdepth: 1

use/key
use/x402
use/cli
use/api

Expand Down
8 changes: 8 additions & 0 deletions docs/intro/basic.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@
Basic usage
===========

.. include:: /../README.rst
:start-after: basic-key-start
:end-before: basic-key-end

To use x402_ instead, see :ref:`x402`.

.. _x402: https://www.x402.org/

.. include:: /../README.rst
:start-after: basic-start
:end-before: basic-end
5 changes: 3 additions & 2 deletions docs/use/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
Python client library
=====================

Once you have :ref:`installed python-zyte-api <install>` and :ref:`configured
your API key <api-key>`, you can use one of its APIs from Python code:
Once you have :ref:`installed python-zyte-api <install>` and configured your
:ref:`API key <api-key>` or :ref:`Ethereum private key <x402>`, you can use one
of its APIs from Python code:

- The :ref:`sync API <sync>` can be used to build simple, proof-of-concept or
debugging Python scripts.
Expand Down
5 changes: 3 additions & 2 deletions docs/use/cli.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
Command-line client
===================

Once you have :ref:`installed python-zyte-api <install>` and :ref:`configured
your API key <api-key>`, you can use the ``zyte-api`` command-line client.
Once you have :ref:`installed python-zyte-api <install>` and configured your
:ref:`API key <api-key>` or :ref:`Ethereum private key <x402>`, you can use the
``zyte-api`` command-line client.

To use ``zyte-api``, pass an :ref:`input file <input-file>` as the first
parameter and specify an :ref:`output file <output-file>` with ``--output``.
Expand Down
55 changes: 55 additions & 0 deletions docs/use/x402.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
.. _x402:

====
x402
====

It is possible to use :ref:`Zyte API <zyte-api>` without a Zyte API account by
using the x402_ protocol to handle payments.

During :ref:`installation <install>`, make sure to install the ``x402`` extra.

Then, configure the *private* key of your Ethereum_ account as follows so that
it can be used to authorize payments.

.. _Ethereum: https://ethereum.org/

It is recommended to configure your Ethereum private key through an environment
variable, so that it can be picked by both the :ref:`command-line client
<command_line>` and the :ref:`Python client library <api>`:

- On Windows’ CMD:

.. code-block:: shell

> set ZYTE_API_ETH_KEY=YOUR_ETH_PRIVATE_KEY

- On macOS and Linux:

.. code-block:: shell

$ export ZYTE_API_ETH_KEY=YOUR_ETH_PRIVATE_KEY

Alternatively, you may pass your Ethereum private key to the clients directly:

- To pass your Ethereum private key directly to the command-line client, use
the ``--eth-key`` switch:

.. code-block:: shell

zyte-api --eth-key YOUR_ETH_PRIVATE_KEY …

- To pass your Ethereum private key directly to the Python client classes,
use the ``eth_key`` parameter when creating a client object:

.. code-block:: python

from zyte_api import ZyteAPI

client = ZyteAPI(eth_key="YOUR_ETH_PRIVATE_KEY")

.. code-block:: python

from zyte_api import AsyncZyteAPI

client = AsyncZyteAPI(eth_key="YOUR_ETH_PRIVATE_KEY")
6 changes: 6 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@
"tqdm",
"w3lib >= 2.1.1",
],
extras_require={
"x402": [
"eth-account",
"x402",
]
},
classifiers=[
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
Expand Down
32 changes: 29 additions & 3 deletions zyte_api/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ async def run(
api_key=None,
retry_errors=True,
store_errors=None,
eth_key=None,
):
if stop_on_errors is not _UNSET:
warn(
Expand All @@ -54,8 +55,13 @@ def write_output(content):
pbar.update()

retrying = None if retry_errors else DontRetryErrorsFactory().build()
auth_kwargs = {}
if api_key:
auth_kwargs["api_key"] = api_key
elif eth_key:
auth_kwargs["eth_key"] = eth_key
client = AsyncZyteAPI(
n_conn=n_conn, api_key=api_key, api_url=api_url, retrying=retrying
n_conn=n_conn, api_url=api_url, retrying=retrying, **auth_kwargs
)
async with create_session(connection_pool_size=n_conn) as session:
result_iter = client.iter(
Expand Down Expand Up @@ -154,9 +160,28 @@ def _get_argument_parser(program_name="zyte-api"):
default=20,
help=("Number of concurrent connections to use (default: %(default)s)."),
)
p.add_argument(
group = p.add_mutually_exclusive_group(required=False)
group.add_argument(
"--api-key",
help="Zyte API key.",
help=(
"Zyte API key.\n"
"\n"
"If not specified, it is read from the ZYTE_API_KEY environment "
"variable."
"\n"
"Cannot be combined with --eth-key."
),
)
group.add_argument(
"--eth-key",
help=(
"Ethereum private key, as a hexadecimal string.\n"
"\n"
"If not specified, it is read from the ZYTE_API_ETH_KEY "
"environment variable."
"\n"
"Cannot be combined with --api-key."
),
)
p.add_argument(
"--api-url", help="Zyte API endpoint (default: %(default)s).", default=API_URL
Expand Down Expand Up @@ -218,6 +243,7 @@ def _main(program_name="zyte-api"):
n_conn=args.n_conn,
api_url=args.api_url,
api_key=args.api_key,
eth_key=args.eth_key,
retry_errors=not args.dont_retry_errors,
store_errors=args.store_errors,
)
Expand Down
51 changes: 40 additions & 11 deletions zyte_api/_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@
import aiohttp
from tenacity import AsyncRetrying

from zyte_api._x402 import _get_eth_key, _get_x402_headers

from ._errors import RequestError
from ._retry import zyte_api_retrying
from ._utils import _AIO_API_TIMEOUT, create_session
from .apikey import get_apikey
from .apikey import NoApiKey, get_apikey
from .constants import API_URL
from .stats import AggStats, ResponseStats
from .utils import USER_AGENT, _process_query
Expand Down Expand Up @@ -86,18 +88,35 @@ class AsyncZyteAPI:
def __init__(
self,
*,
api_key=None,
api_url=API_URL,
n_conn=15,
api_key: str | None = None,
api_url: str = API_URL,
n_conn: int = 15,
retrying: AsyncRetrying | None = None,
user_agent: str | None = None,
eth_key: str | None = None,
):
if retrying is not None and not isinstance(retrying, AsyncRetrying):
raise ValueError(
"The retrying parameter, if defined, must be an instance of "
"AsyncRetrying."
)
self.api_key = get_apikey(api_key)

try:
self.auth = get_apikey(api_key)
except NoApiKey:
try:
eth_key = _get_eth_key(eth_key)
except ValueError:
raise NoApiKey(
"You must provide either a Zyte API key or an Ethereum private key."
) from None

from eth_account import Account
from x402.clients import x402Client

account = Account.from_key(eth_key)
self.auth = x402Client(account=account)

self.api_url = api_url
self.n_conn = n_conn
self.agg_stats = AggStats()
Expand All @@ -117,22 +136,32 @@ async def get(
"""Asynchronous equivalent to :meth:`ZyteAPI.get`."""
retrying = retrying or self.retrying
post = _post_func(session)
auth = aiohttp.BasicAuth(self.api_key)

url = self.api_url + endpoint
query = _process_query(query)
headers = {"User-Agent": self.user_agent, "Accept-Encoding": "br"}

auth_kwargs = {}
if isinstance(self.auth, str):
auth_kwargs["auth"] = aiohttp.BasicAuth(self.auth)
else:
x402_headers = await _get_x402_headers(
self.auth, url, query, headers, self._semaphore, post
)
headers.update(x402_headers)

response_stats = []
start_global = time.perf_counter()

async def request():
stats = ResponseStats.create(start_global)
self.agg_stats.n_attempts += 1

safe_query = _process_query(query)
post_kwargs = {
"url": self.api_url + endpoint,
"json": safe_query,
"auth": auth,
"url": url,
"json": query,
"headers": headers,
**auth_kwargs,
}

try:
Expand All @@ -151,7 +180,7 @@ async def request():
message=resp.reason,
headers=resp.headers,
response_content=content,
query=safe_query,
query=query,
)

response = await resp.json()
Expand Down
12 changes: 9 additions & 3 deletions zyte_api/_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ class ZyteAPI:
*api_key* is your Zyte API key. If not specified, it is read from the
``ZYTE_API_KEY`` environment variable. See :ref:`api-key`.

Alternatively, you can set an Ethereum private key through *eth_key* to use
Ethereum for payments. If not specified, it is read from the
``ZYTE_API_ETH_KEY`` environment variable. See :ref:`x402`.

*api_url* is the Zyte API base URL.

*n_conn* is the maximum number of concurrent requests to use. See
Expand All @@ -101,18 +105,20 @@ class ZyteAPI:
def __init__(
self,
*,
api_key=None,
api_url=API_URL,
n_conn=15,
api_key: str | None = None,
api_url: str = API_URL,
n_conn: int = 15,
retrying: AsyncRetrying | None = None,
user_agent: str | None = None,
eth_key: str | None = None,
):
self._async_client = AsyncZyteAPI(
api_key=api_key,
api_url=api_url,
n_conn=n_conn,
retrying=retrying,
user_agent=user_agent,
eth_key=eth_key,
)

def get(
Expand Down
Loading