Skip to content
Draft
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
16 changes: 16 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,22 @@
Changes
=======

0.11.0 (unreleased)
-------------------

* The ``ZYTE_API_KEY`` and ``ZYTE_API_ETH_KEY`` credentials can now be read
from a ``.env`` file (via `python-dotenv
<https://pypi.org/project/python-dotenv/>`_) when they are not set in the
environment. Environment variables take precedence and the environment is
never modified. A custom ``.env`` location can be set with the new
``--dotenv-path`` command-line switch or the new ``dotenv_path`` parameter of
:class:`~zyte_api.ZyteAPI` and :class:`~zyte_api.AsyncZyteAPI`.

``ZYTE_API_KEY`` is looked up in the nearest ``.env`` file in the current
directory or its parents, but the ``ZYTE_API_ETH_KEY`` Ethereum private key
is read only from a ``.env`` file in the current directory, to limit the risk
of loading a fund-controlling secret from an unrelated ``.env`` file.

0.10.0 (2026-04-28)
-------------------

Expand Down
27 changes: 27 additions & 0 deletions docs/use/key.rst
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,33 @@ the :ref:`Python client library <api>`:

$ export ZYTE_API_KEY=YOUR_API_KEY

Instead of exporting the variable yourself, you can store it in a ``.env`` file
and let it be loaded automatically:

.. code-block:: shell
:caption: .env

ZYTE_API_KEY=YOUR_API_KEY

The nearest ``.env`` file is looked up in the current working directory and its
parent directories. Only the ``ZYTE_API_KEY`` variable is read from it; any
other variables are ignored, a ``ZYTE_API_KEY`` set in the environment takes
precedence over the file, and your environment is never modified.

To read a ``.env`` file from a different location, use the ``--dotenv-path``
switch of the :ref:`command-line client <command_line>`, or the ``dotenv_path``
parameter of the :ref:`Python client classes <api>`:

.. code-block:: shell

zyte-api --dotenv-path /path/to/.env …

.. code-block:: python

from zyte_api import ZyteAPI

client = ZyteAPI(dotenv_path="/path/to/.env")

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

- To pass your API key directly to the command-line client, use the
Expand Down
20 changes: 20 additions & 0 deletions docs/use/x402.rst
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,23 @@ Alternatively, you may pass your Ethereum private key to the clients directly:
from zyte_api import AsyncZyteAPI

client = AsyncZyteAPI(eth_key="YOUR_ETH_PRIVATE_KEY")

You may also store your Ethereum private key in a ``.env`` file:

.. code-block:: shell
:caption: .env

ZYTE_API_ETH_KEY=YOUR_ETH_PRIVATE_KEY

Unlike :ref:`the Zyte API key <api-key>`, the Ethereum private key is read
**only** from a ``.env`` file in the current directory; parent directories are
not searched, to limit the risk of loading it from an unrelated ``.env`` file.
To read it from a different location, use the ``--dotenv-path`` switch or the
``dotenv_path`` parameter, which apply to both credentials. The environment
variable takes precedence over the file, which is never modified.

.. warning:: Your Ethereum private key controls your funds. Storing it in a
``.env`` file is convenient but risky: keep the file out of version control
(e.g. add it to ``.gitignore``) and prefer a dedicated secrets manager for
anything beyond local development. A leaked private key cannot be revoked or
rotated like a Zyte API key.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ dependencies = [
"aiohttp>=3.8.0",
"attrs>=20.1.0",
"brotli>=0.5.2",
"python-dotenv>=1.0.0",
"runstats>=0.0.1",
"tenacity>=8.2.0",
"tqdm>=4.16.0",
Expand Down
11 changes: 11 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
import pytest


@pytest.fixture(autouse=True)
def isolated_apikey_env(tmp_path, monkeypatch):
"""Keep API-key resolution hermetic: drop ambient key env vars and run from
an empty directory so ``find_dotenv()`` can't pick up a stray ``.env`` from
the developer's working tree. Tests that need a ``.env`` create it in the
(now empty) working directory."""
monkeypatch.delenv("ZYTE_API_KEY", raising=False)
monkeypatch.delenv("ZYTE_API_ETH_KEY", raising=False)
monkeypatch.chdir(tmp_path)


@pytest.fixture(scope="session")
def mockserver():
from .mockserver import MockServer # noqa: PLC0415
Expand Down
87 changes: 86 additions & 1 deletion tests/test_apikey.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import os

import pytest

from zyte_api.apikey import NoApiKey, get_apikey
from zyte_api.apikey import (
NoApiKey,
get_apikey,
read_apikey_from_dotenv,
read_dotenv_auth,
)


def test_get_apikey(monkeypatch):
Expand All @@ -13,3 +20,81 @@ def test_get_apikey(monkeypatch):
assert get_apikey("a") == "a"
assert get_apikey() == "b"
assert get_apikey(None) == "b"


def test_get_apikey_from_dotenv(tmp_path):
# The autouse fixture already chdir'd into the empty tmp_path.
(tmp_path / ".env").write_text("ZYTE_API_KEY=fromdotenv\n")

assert get_apikey() == "fromdotenv"
# An explicit key still wins.
assert get_apikey("explicit") == "explicit"
# Reading the file must not modify the environment.
assert "ZYTE_API_KEY" not in os.environ


def test_get_apikey_from_dotenv_parent_dir(tmp_path, monkeypatch):
(tmp_path / ".env").write_text("ZYTE_API_KEY=fromparent\n")
subdir = tmp_path / "project" / "subdir"
subdir.mkdir(parents=True)
monkeypatch.chdir(subdir)

assert get_apikey() == "fromparent"


def test_get_apikey_env_takes_precedence_over_dotenv(tmp_path, monkeypatch):
(tmp_path / ".env").write_text("ZYTE_API_KEY=fromdotenv\n")
monkeypatch.setenv("ZYTE_API_KEY", "fromenv")

assert get_apikey() == "fromenv"


def test_read_apikey_from_dotenv(tmp_path):
(tmp_path / ".env").write_text("ZYTE_API_KEY=fromdotenv\nOTHER=ignored\n")

assert read_apikey_from_dotenv() == "fromdotenv"
assert "ZYTE_API_KEY" not in os.environ
assert "OTHER" not in os.environ


def test_read_apikey_from_dotenv_missing(tmp_path):
# Empty working directory, no .env anywhere relevant.
assert read_apikey_from_dotenv() is None


def test_read_apikey_from_dotenv_custom_path(tmp_path):
env_file = tmp_path / "custom.env"
env_file.write_text("ZYTE_API_KEY=fromcustom\n")

assert read_apikey_from_dotenv(str(env_file)) == "fromcustom"


def test_read_dotenv_auth_reads_both_credentials(tmp_path):
(tmp_path / ".env").write_text(
"ZYTE_API_KEY=k\nZYTE_API_ETH_KEY=e\nOTHER=ignored\n"
)

assert read_dotenv_auth() == {"ZYTE_API_KEY": "k", "ZYTE_API_ETH_KEY": "e"}
assert "ZYTE_API_KEY" not in os.environ
assert "ZYTE_API_ETH_KEY" not in os.environ


def test_read_dotenv_auth_eth_key_not_read_from_parent(tmp_path, monkeypatch):
# Both credentials live in a parent .env, but only the API key is read from
# there; the Ethereum private key is never looked up in parent directories.
(tmp_path / ".env").write_text(
"ZYTE_API_KEY=fromparent\nZYTE_API_ETH_KEY=ethfromparent\n"
)
subdir = tmp_path / "project" / "subdir"
subdir.mkdir(parents=True)
monkeypatch.chdir(subdir)

assert read_dotenv_auth() == {"ZYTE_API_KEY": "fromparent"}


def test_read_dotenv_auth_explicit_path_reads_eth(tmp_path):
# An explicit path is honored for both credentials (no walking involved).
env_file = tmp_path / "custom.env"
env_file.write_text("ZYTE_API_ETH_KEY=e\n")

assert read_dotenv_auth(str(env_file)) == {"ZYTE_API_ETH_KEY": "e"}
19 changes: 19 additions & 0 deletions tests/test_async.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import asyncio
import os
from typing import TYPE_CHECKING, Any
from unittest.mock import AsyncMock, patch

Expand Down Expand Up @@ -55,6 +56,24 @@ def test_api_key(client_cls):
client_cls()


@pytest.mark.parametrize(
"client_cls",
(
AsyncZyteAPI,
AsyncClient,
),
)
def test_api_key_from_dotenv(client_cls, tmp_path):
# The autouse fixture already chdir'd into the empty tmp_path.
(tmp_path / ".env").write_text("ZYTE_API_KEY=fromdotenv\n")

client = client_cls()
assert client.auth.key == "fromdotenv"
# An explicit api_key still wins, and the environment is never modified.
assert client_cls(api_key="explicit").auth.key == "explicit"
assert "ZYTE_API_KEY" not in os.environ


@pytest.mark.asyncio
async def test_session_inherits_client_trust_env(mockserver):
client = AsyncZyteAPI(api_key="a", api_url=mockserver.urljoin("/"), trust_env=True)
Expand Down
28 changes: 28 additions & 0 deletions tests/test_auth.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from base64 import b64encode
from contextlib import asynccontextmanager
from os import environ
from pathlib import Path
from subprocess import run
from tempfile import NamedTemporaryFile
from unittest.mock import AsyncMock, MagicMock, patch
Expand Down Expand Up @@ -70,6 +71,33 @@ def test(scenario, expected, mockserver):
assert result.returncode == 0


def test_dotenv_cli(mockserver, tmp_path):
# The autouse fixture chdir'd into the empty tmp_path, so there is no key
# anywhere yet.
result = run_zyte_api([], {}, mockserver)
assert b"NoApiKey" in result.stderr
assert result.returncode == 1

# ZYTE_API_KEY read from the nearest .env (the current directory).
Path(".env").write_text("ZYTE_API_KEY=fromdotenv\n", encoding="utf8")
result = run_zyte_api([], {}, mockserver)
assert result.returncode == 0, result.stderr

# --dotenv-path points at a different file.
Path(".env").unlink()
custom = tmp_path / "custom.env"
custom.write_text("ZYTE_API_KEY=fromcustom\n", encoding="utf8")
result = run_zyte_api(["--dotenv-path", str(custom)], {}, mockserver)
assert result.returncode == 0, result.stderr


@pytest.mark.skipif(not HAS_X402, reason="x402 extra not installed")
def test_dotenv_cli_eth_key(mockserver, tmp_path):
Path(".env").write_text(f"ZYTE_API_ETH_KEY={ETH_KEY}\n", encoding="utf8")
result = run_zyte_api([], {}, mockserver)
assert result.returncode == 0, result.stderr


@pytest.mark.parametrize(
("scenario", "expected"),
(
Expand Down
15 changes: 15 additions & 0 deletions zyte_api/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ async def run(
store_errors: bool | None = None,
eth_key: str | None = None,
trust_env: bool = False,
dotenv_path: str | None = None,
) -> None:
if stop_on_errors is not _UNSET:
warn(
Expand Down Expand Up @@ -72,6 +73,7 @@ def write_output(content: Any) -> None:
api_url=api_url,
retrying=retrying,
trust_env=trust_env,
dotenv_path=dotenv_path,
**auth_kwargs,
)
async with create_session(
Expand Down Expand Up @@ -196,6 +198,18 @@ def _get_argument_parser(program_name: str = "zyte-api") -> argparse.ArgumentPar
"Cannot be combined with --api-key."
),
)
p.add_argument(
"--dotenv-path",
help=(
"Path to a .env file to read the ZYTE_API_KEY or ZYTE_API_ETH_KEY "
"credentials from when they are not passed explicitly or set in "
"the environment.\n"
"\n"
"If omitted, ZYTE_API_KEY is read from the nearest .env file in the "
"current directory or its parents, while ZYTE_API_ETH_KEY is read "
"only from a .env file in the current directory."
),
)
p.add_argument(
"--api-url",
help=(
Expand Down Expand Up @@ -280,6 +294,7 @@ def _main(program_name: str = "zyte-api") -> None:
"retry_errors": not args.dont_retry_errors,
"store_errors": args.store_errors,
"trust_env": args.trust_env,
"dotenv_path": args.dotenv_path,
}
if args.output is None or args.output == "-":
with nullcontext(sys.stdout) as out:
Expand Down
32 changes: 23 additions & 9 deletions zyte_api/_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@
from tenacity import AsyncRetrying

from zyte_api._x402 import _x402Handler
from zyte_api.apikey import NoApiKey
from zyte_api.apikey import NoApiKey, read_dotenv_auth

from ._errors import RequestError
from ._retry import zyte_api_retrying
from ._utils import _AIO_API_TIMEOUT, create_session
from .constants import API_URL
from .constants import ENV_VARIABLE as API_KEY_ENV_VAR
from .constants import ETH_ENV_VARIABLE as ETH_KEY_ENV_VAR
from .stats import AggStats, ResponseStats
from .utils import USER_AGENT, _process_query

Expand Down Expand Up @@ -125,6 +126,7 @@ def __init__(
user_agent: str | None = None,
eth_key: str | None = None,
trust_env: bool = False,
dotenv_path: str | None = None,
):
if retrying is not None and not isinstance(retrying, AsyncRetrying):
raise ValueError(
Expand All @@ -141,25 +143,37 @@ def __init__(
self._auth: str | _x402Handler
self.auth: AuthInfo
self.api_url: str
self._load_auth(api_key, eth_key, api_url)
self._load_auth(api_key, eth_key, api_url, dotenv_path)

def _load_auth(
self, api_key: str | None, eth_key: str | None, api_url: str | None
self,
api_key: str | None,
eth_key: str | None,
api_url: str | None,
dotenv_path: str | None = None,
) -> None:
if api_key:
self._auth = api_key
elif eth_key:
self._auth = _x402Handler(eth_key, self._semaphore, self.agg_stats)
elif api_key := environ.get(API_KEY_ENV_VAR):
self._auth = api_key
elif eth_key := environ.get("ZYTE_API_ETH_KEY"):
elif eth_key := environ.get(ETH_KEY_ENV_VAR):
self._auth = _x402Handler(eth_key, self._semaphore, self.agg_stats)
else:
raise NoApiKey(
"You must provide either a Zyte API key or an Ethereum "
"private key. For the latter, you must also install "
"zyte-api as zyte-api[x402]."
)
# Fall back to a .env file only when the environment has no
# credentials, so an exported key never triggers a file lookup.
dotenv = read_dotenv_auth(dotenv_path)
if api_key := dotenv.get(API_KEY_ENV_VAR):
self._auth = api_key
elif eth_key := dotenv.get(ETH_KEY_ENV_VAR):
self._auth = _x402Handler(eth_key, self._semaphore, self.agg_stats)
else:
raise NoApiKey(
"You must provide either a Zyte API key or an Ethereum "
"private key. For the latter, you must also install "
"zyte-api as zyte-api[x402]."
)
self.auth = AuthInfo(_auth=self._auth)
self.api_url = (
api_url
Expand Down
Loading
Loading