Skip to content
Open
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
9 changes: 3 additions & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -89,17 +89,14 @@ patch = [
]

[tool.mypy]
allow_untyped_defs = false
implicit_reexport = false
strict = true
# Unannotated third-party functions.
untyped_calls_exclude = ["twisted"]

[[tool.mypy.overrides]]
module = "runstats"
ignore_missing_imports = true

[[tool.mypy.overrides]]
module = "tests.*"
allow_untyped_defs = true

[tool.pytest.ini_options]
filterwarnings = [
"ignore:The zyte_api\\.aio module is deprecated:DeprecationWarning"
Expand Down
14 changes: 12 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
from __future__ import annotations

from typing import TYPE_CHECKING

import pytest

if TYPE_CHECKING:
from collections.abc import Generator
from pathlib import Path

from .mockserver import MockServer


@pytest.fixture(autouse=True)
def isolated_apikey_env(tmp_path, monkeypatch):
def isolated_apikey_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""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
Expand All @@ -13,7 +23,7 @@ def isolated_apikey_env(tmp_path, monkeypatch):


@pytest.fixture(scope="session")
def mockserver():
def mockserver() -> Generator[MockServer]:
from .mockserver import MockServer # noqa: PLC0415

with MockServer() as server:
Expand Down
73 changes: 52 additions & 21 deletions tests/mockserver.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

import argparse
import json
import socket
Expand All @@ -7,12 +9,19 @@
from collections import defaultdict
from importlib import import_module
from subprocess import PIPE, Popen
from typing import Any
from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse

from twisted.internet.task import deferLater
from twisted.web.resource import Resource
from twisted.web.server import NOT_DONE_YET, Site
from twisted.web.server import NOT_DONE_YET, Request, Site

if TYPE_CHECKING:
from collections.abc import Callable
from types import TracebackType

from twisted.internet.defer import Deferred
from typing_extensions import Self

SCREENSHOT = (
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQott"
Expand All @@ -21,7 +30,13 @@


# https://github.com/scrapy/scrapy/blob/02b97f98e74a994ad3e4d74e7ed55207e508a576/tests/mockserver.py#L27C1-L33C19
def getarg(request, name, default=None, type_=None):
def getarg(
request: Request,
name: bytes,
default: Any = None,
type_: Callable[[bytes], Any] | None = None,
) -> Any:
assert request.args is not None
if name in request.args:
value = request.args[name][0]
if type_ is not None:
Expand All @@ -30,36 +45,44 @@ def getarg(request, name, default=None, type_=None):
return default


def get_ephemeral_port():
def get_ephemeral_port() -> int:
s = socket.socket()
s.bind(("", 0))
return s.getsockname()[1]
port: int = s.getsockname()[1]
return port


class DropResource(Resource):
isLeaf = True

def deferRequest(self, request, delay, f, *a, **kw):
def deferRequest(
self,
request: Request,
delay: float,
f: Callable[..., Any],
*a: Any,
**kw: Any,
) -> Deferred[Any]:
from twisted.internet import reactor

def _cancelrequest(_):
def _cancelrequest(_: Any) -> None:
# silence CancelledError
d.addErrback(lambda _: None)
d.cancel()

d = deferLater(reactor, delay, f, *a, **kw)
d = deferLater(reactor, delay, f, *a, **kw) # type: ignore[arg-type]
request.notifyFinish().addErrback(_cancelrequest)
return d

def render_POST(self, request):
def render_POST(self, request: Request) -> int:
request.setHeader(b"Content-Length", b"1024")
self.deferRequest(request, 0, self._delayedRender, request)
return NOT_DONE_YET

def _delayedRender(self, request):
def _delayedRender(self, request: Request) -> None:
abort = getarg(request, b"abort", 0, type_=int)
request.write(b"this connection will be dropped\n")
tr = request.channel.transport
tr: Any = request.channel.transport
try:
if abort and hasattr(tr, "abortConnection"):
tr.abortConnection()
Expand Down Expand Up @@ -94,10 +117,10 @@ def _delayedRender(self, request):
class DefaultResource(Resource):
request_count = 0

def getChild(self, path, request):
def getChild(self, path: bytes, request: Request) -> Resource:
return self

def render_POST(self, request):
def render_POST(self, request: Request) -> bytes:
request.responseHeaders.setRawHeaders(
b"Content-Type",
[b"application/json"],
Expand All @@ -107,6 +130,7 @@ def render_POST(self, request):
[b"abcd1234"],
)

assert request.content is not None
request_data = json.loads(request.content.read())
response_data: dict[str, Any]

Expand All @@ -121,7 +145,7 @@ def render_POST(self, request):
return b""
if domain == "e500.example":
request.setResponseCode(500)
return ""
return b""
if domain == "e520.example":
request.setResponseCode(520)
response_data = {"status": 520, "type": "/download/temporary-error"}
Expand Down Expand Up @@ -235,15 +259,17 @@ def render_POST(self, request):


class MockServer:
def __init__(self, resource=None, port=None):
def __init__(
self, resource: type[Resource] | None = None, port: int | None = None
) -> None:
resource = resource or DefaultResource
self.resource = f"{resource.__module__}.{resource.__name__}"
self.proc = None
self.proc: Popen[bytes] | None = None
self.host = socket.gethostbyname(socket.gethostname())
self.port = port or get_ephemeral_port()
self.root_url = f"http://{self.host}:{self.port}"

def __enter__(self):
def __enter__(self) -> Self:
self.proc = Popen(
[
sys.executable,
Expand All @@ -260,17 +286,22 @@ def __enter__(self):
self.proc.stdout.readline()
return self

def __exit__(self, exc_type, exc_value, traceback):
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> None:
assert self.proc is not None
self.proc.kill()
self.proc.wait()
time.sleep(0.2)

def urljoin(self, path):
def urljoin(self, path: str) -> str:
return self.root_url + path


def main():
def main() -> None:
from twisted.internet import reactor

parser = argparse.ArgumentParser()
Expand All @@ -283,7 +314,7 @@ def main():
# Typing issue: https://github.com/twisted/twisted/issues/9909
http_port = reactor.listenTCP(args.port, Site(resource)) # type: ignore[attr-defined]

def print_listening():
def print_listening() -> None:
host = http_port.getHost()
print(f"Mock server {resource} running at http://{host.host}:{host.port}")

Expand Down
32 changes: 22 additions & 10 deletions tests/test_apikey.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
from __future__ import annotations

import os
from typing import TYPE_CHECKING

import pytest

Expand All @@ -9,8 +12,11 @@
read_dotenv_auth,
)

if TYPE_CHECKING:
from pathlib import Path


def test_get_apikey(monkeypatch):
def test_get_apikey(monkeypatch: pytest.MonkeyPatch) -> None:
assert get_apikey("a") == "a"
with pytest.raises(NoApiKey):
get_apikey()
Expand All @@ -22,7 +28,7 @@ def test_get_apikey(monkeypatch):
assert get_apikey(None) == "b"


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

Expand All @@ -33,7 +39,9 @@ def test_get_apikey_from_dotenv(tmp_path):
assert "ZYTE_API_KEY" not in os.environ


def test_get_apikey_from_dotenv_parent_dir(tmp_path, monkeypatch):
def test_get_apikey_from_dotenv_parent_dir(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
(tmp_path / ".env").write_text("ZYTE_API_KEY=fromparent\n")
subdir = tmp_path / "project" / "subdir"
subdir.mkdir(parents=True)
Expand All @@ -42,34 +50,36 @@ def test_get_apikey_from_dotenv_parent_dir(tmp_path, monkeypatch):
assert get_apikey() == "fromparent"


def test_get_apikey_env_takes_precedence_over_dotenv(tmp_path, monkeypatch):
def test_get_apikey_env_takes_precedence_over_dotenv(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
(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):
def test_read_apikey_from_dotenv(tmp_path: Path) -> None:
(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):
def test_read_apikey_from_dotenv_missing(tmp_path: Path) -> None:
# Empty working directory, no .env anywhere relevant.
assert read_apikey_from_dotenv() is None


def test_read_apikey_from_dotenv_custom_path(tmp_path):
def test_read_apikey_from_dotenv_custom_path(tmp_path: Path) -> None:
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):
def test_read_dotenv_auth_reads_both_credentials(tmp_path: Path) -> None:
(tmp_path / ".env").write_text(
"ZYTE_API_KEY=k\nZYTE_API_ETH_KEY=e\nOTHER=ignored\n"
)
Expand All @@ -79,7 +89,9 @@ def test_read_dotenv_auth_reads_both_credentials(tmp_path):
assert "ZYTE_API_ETH_KEY" not in os.environ


def test_read_dotenv_auth_eth_key_not_read_from_parent(tmp_path, monkeypatch):
def test_read_dotenv_auth_eth_key_not_read_from_parent(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# 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(
Expand All @@ -92,7 +104,7 @@ def test_read_dotenv_auth_eth_key_not_read_from_parent(tmp_path, monkeypatch):
assert read_dotenv_auth() == {"ZYTE_API_KEY": "fromparent"}


def test_read_dotenv_auth_explicit_path_reads_eth(tmp_path):
def test_read_dotenv_auth_explicit_path_reads_eth(tmp_path: Path) -> None:
# 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")
Expand Down
Loading
Loading