Skip to content

Commit a24fd73

Browse files
committed
style(scrapy): tighten comments and docstrings
1 parent 0aa1fab commit a24fd73

3 files changed

Lines changed: 32 additions & 60 deletions

File tree

src/apify/scrapy/_serialization.py

Lines changed: 10 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,30 +6,21 @@
66

77
from pydantic import BaseModel
88

9-
# Scrapy persists requests and cached responses by serializing the dict produced by
10-
# `Request.to_dict()` (and a small response payload for the HTTP cache). The Apify integration
11-
# stores that payload inside the request queue and the key-value store and reads it back later.
12-
# Those storages hold JSON, so the payload is serialized as JSON here rather than as a pickled
13-
# Python object graph: JSON is a plain, portable, interoperable data format and carries no
14-
# executable object state.
9+
# Scrapy requests and cached responses are stored in the Apify request queue and key-value store,
10+
# which hold JSON, so they are serialized as JSON here rather than pickled.
1511
#
16-
# JSON cannot represent everything Scrapy emits. Of the values that actually appear, only two are
17-
# not natively JSON-serializable, and both sit at fixed, known keys with a known type:
18-
# - `body`: `bytes`
19-
# - `headers`: a `{bytes: [bytes]}` mapping (header name -> list of values)
20-
# These are base64-encoded in place. Pydantic models (e.g. Crawlee's `UserData`, which the Apify
21-
# integration injects into `meta['userData']`) are converted via `model_dump()`. Everything else —
22-
# notably the user-controlled `meta` and `cb_kwargs` — must already be JSON-serializable; if it is
23-
# not, serialization fails with a clear error rather than silently dropping the request. No in-band
24-
# sentinel is used for user data, so no legitimate value can collide with the encoding scheme.
12+
# Only `body` (`bytes`) and `headers` (`{bytes: [bytes]}`) are not natively JSON-serializable; both
13+
# sit at fixed keys and are base64-encoded in place. Pydantic models such as Crawlee's `UserData`
14+
# are dumped via `model_dump()`. Everything else, notably `meta` and `cb_kwargs`, must already be
15+
# JSON-serializable, otherwise serialization fails with a clear error. No in-band sentinel is used,
16+
# so no user value can collide with the encoding.
2517

2618

2719
def encode_to_json(data: dict[str, Any]) -> str:
2820
"""Serialize a Scrapy request/response dict to a JSON string.
2921
30-
The binary `body` and `headers` fields are base64-encoded in place. All other fields must be
31-
JSON-serializable; pydantic models are dumped to plain dicts. A clear `TypeError` is raised if
32-
any remaining value (typically something in `meta` or `cb_kwargs`) cannot be JSON-encoded.
22+
The binary `body` and `headers` fields are base64-encoded in place; pydantic models are dumped
23+
to plain dicts. A `TypeError` is raised if any other value cannot be JSON-encoded.
3324
3425
Args:
3526
data: The dict to serialize, e.g. the output of `scrapy.Request.to_dict()`.
@@ -83,11 +74,7 @@ def decode_from_json(text: str) -> Any:
8374

8475

8576
def _json_default(obj: Any) -> Any:
86-
"""Fallback for values `json.dumps` cannot serialize on its own.
87-
88-
Only pydantic models are accepted (and dumped to plain dicts); anything else raises, which
89-
`encode_to_json` turns into an actionable error.
90-
"""
77+
"""Fallback for values `json.dumps` cannot serialize: pydantic models are dumped, anything else raises."""
9178
if isinstance(obj, BaseModel):
9279
return obj.model_dump(by_alias=True)
9380
raise TypeError(f'Object of type {type(obj).__name__} is not JSON-serializable')

src/apify/scrapy/extensions/_httpcache.py

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,8 @@ class ApifyCacheStorage:
3636
"""
3737

3838
def __init__(self, settings: BaseSettings) -> None:
39-
# Upper bound on how many keys the per-spider-close cleanup sweeps. Configurable because the
40-
# cleanup is bounded and best-effort (see `close_spider`); the default keeps the historical
41-
# behavior.
39+
# Upper bound on how many keys the per-spider-close cleanup sweeps (best-effort; see
40+
# `close_spider`).
4241
self._expiration_max_items: int = settings.getint('APIFY_HTTPCACHE_EXPIRATION_MAX_ITEMS', 100)
4342
self._expiration_secs: int = settings.getint('HTTPCACHE_EXPIRATION_SECS')
4443
self._spider: Spider | None = None
@@ -82,10 +81,9 @@ def close_spider(self, _: Spider, current_time: int | None = None) -> None:
8281
async def expire_kvs() -> None:
8382
if self._kvs is None:
8483
raise ValueError('Key value store not initialized')
85-
# Best-effort, bounded cleanup: at most `_expiration_max_items` keys are swept per
86-
# spider close and `iterate_keys()` order is not guaranteed, so stale entries may
87-
# linger across runs. This only reclaims storage; correctness is handled at read
88-
# time, where `retrieve_response` treats an expired entry as a cache miss.
84+
# Best-effort cleanup: at most `_expiration_max_items` keys per close, in no
85+
# guaranteed order, so stale entries may linger. This only reclaims storage;
86+
# `retrieve_response` already treats an expired entry as a cache miss.
8987
processed = 0
9088
async for item in self._kvs.iterate_keys():
9189
if processed >= self._expiration_max_items:
@@ -135,8 +133,8 @@ def retrieve_response(self, _: Spider, request: Request, current_time: int | Non
135133
if current_time is None:
136134
current_time = int(time())
137135

138-
# A malformed or legacy (e.g. pickle-format) cache entry must not crash retrieval. Treat it
139-
# as a cache miss so Scrapy re-fetches and re-stores it in the current format.
136+
# A malformed or legacy cache entry must not crash retrieval; treat it as a cache miss so
137+
# Scrapy re-fetches and re-stores it in the current format.
140138
try:
141139
if 0 < self._expiration_secs < current_time - read_gzip_time(value):
142140
logger.debug('Cache expired', extra={'request': request})
@@ -176,11 +174,10 @@ def store_response(self, _: Spider, request: Request, response: Response) -> Non
176174

177175

178176
def to_gzip(data: dict, mtime: int | None = None) -> bytes:
179-
"""Dump a dictionary to a gzip-compressed byte stream as JSON.
177+
"""Dump a dictionary to a gzip-compressed JSON byte stream.
180178
181-
Cached entries are stored in and read back from the Apify key-value store, which holds JSON, so
182-
the payload is serialized as JSON rather than pickled. See `apify.scrapy._serialization` for the
183-
encoding details.
179+
Cache entries live in the Apify key-value store, which holds JSON, so they are serialized as
180+
JSON rather than pickled. See `apify.scrapy._serialization` for the encoding.
184181
"""
185182
payload = encode_to_json(data).encode('utf-8')
186183
with io.BytesIO() as byte_stream:

src/apify/scrapy/requests.py

Lines changed: 12 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,10 @@
2222
def _ensure_known_request_class(request_dict: dict[str, Any]) -> None:
2323
"""Validate the optional `_class` entry before `request_from_dict` resolves it.
2424
25-
`scrapy.utils.request.request_from_dict` resolves a `_class` entry via `load_object`, which
26-
imports the dotted path it is given. To keep reconstruction self-contained — importing nothing
27-
that the running spider has not already imported — we only accept a `_class` that is already
28-
present in `sys.modules` and is a `scrapy.Request` subclass.
29-
30-
A spider that reads its own requests always has its request classes imported by the time the
31-
requests are reconstructed, so this does not restrict legitimate use.
25+
`request_from_dict` imports the `_class` dotted path via `load_object`. To avoid importing
26+
anything the running spider has not already imported, only a `_class` already present in
27+
`sys.modules` and subclassing `scrapy.Request` is accepted. A spider reading its own requests
28+
always has those classes imported by then, so legitimate use is unaffected.
3229
"""
3330
class_path = request_dict.get('_class')
3431
if class_path is None:
@@ -66,12 +63,8 @@ def to_apify_request(scrapy_request: ScrapyRequest, spider: Spider) -> ApifyRequ
6663

6764
# Configuration to behave as similarly as possible to Scrapy's default RFPDupeFilter.
6865
#
69-
# `payload` carries the request body, which is used both for platform processing and for
70-
# computing the extended unique key. The body is also part of the serialized Scrapy request
71-
# stored further below, where it is needed to faithfully reconstruct the request. Both copies
72-
# originate from `scrapy_request.body` and are kept intentionally: dropping `payload` would
73-
# change deduplication, and dropping the serialized copy would couple reconstruction to the
74-
# Apify payload.
66+
# The body is stored twice on purpose: as `payload` (used for the extended unique key) and inside
67+
# the serialized Scrapy request below (used to reconstruct it). Both come from `scrapy_request.body`.
7568
request_kwargs: dict[str, Any] = {
7669
'url': scrapy_request.url,
7770
'method': scrapy_request.method,
@@ -104,11 +97,9 @@ def to_apify_request(scrapy_request: ScrapyRequest, spider: Spider) -> ApifyRequ
10497

10598
request_kwargs['user_data'] = user_data if isinstance(user_data, dict) else {}
10699

107-
# Convert Scrapy's headers to HttpHeaders and store them on the apify_request. This is only
108-
# the Apify-platform-level view of the headers; the authoritative copy, with exact bytes,
109-
# travels inside the serialized scrapy_request below. `to_unicode_dict()` decodes as UTF-8
110-
# and raises on non-UTF-8 header values, so it is guarded: a request with binary headers
111-
# keeps them in the serialized payload instead of being dropped entirely.
100+
# Store an Apify-platform view of the headers. The authoritative copy with exact bytes
101+
# travels in the serialized scrapy_request below, so non-UTF-8 headers (which make
102+
# `to_unicode_dict()` raise) are tolerated rather than dropping the whole request.
112103
if isinstance(scrapy_request.headers, Headers):
113104
try:
114105
headers = cast('dict[str, str]', dict(scrapy_request.headers.to_unicode_dict()))
@@ -130,12 +121,9 @@ def to_apify_request(scrapy_request: ScrapyRequest, spider: Spider) -> ApifyRequ
130121
logger.warning(f'Conversion of Scrapy request {scrapy_request} to Apify request failed; {exc}')
131122
return None
132123

133-
# Serialize the Scrapy request and store it (base64-encoded JSON) under 'scrapy_request' in the
134-
# Apify request's user data. This is intentionally outside the broad except above so that a
135-
# non-JSON-serializable value in `meta`/`cb_kwargs` is reported loudly rather than hidden as a
136-
# generic warning. The failure is logged with a full traceback and the request is skipped (None
137-
# is returned, honoring this function's contract) instead of crashing the whole crawl. See
138-
# `_serialization` for the encoding details.
124+
# Serialize the Scrapy request as base64-encoded JSON under 'scrapy_request'. Kept outside the
125+
# broad except above so a non-JSON-serializable `meta`/`cb_kwargs` is logged with a traceback and
126+
# the request skipped (returning None per this function's contract), rather than crashing the crawl.
139127
try:
140128
scrapy_request_json = encode_to_json(scrapy_request_dict)
141129
except TypeError:

0 commit comments

Comments
 (0)