chore: simplify some code, get rid of nested fns where it's makes sence + pylint (#7401)

* chore: simplify some code, get rid of nested fns where it's makes sence

reasoning: https://wemake-python-styleguide.readthedocs.io/en/latest/pages/usage/violations/best_practices.html#wemake_python_styleguide.violations.best_practices.NestedFunctionViolation

* chore: address some pylint rules

* remove `pylint: disable=deprecated-class` from `reactor.py`

* fix typo

* fix another typo :)

* revert backward incompatible arg renames

---------

Co-authored-by: Andrey Rakhmatullin <wrar@wrar.name>
This commit is contained in:
Albert Eduardovich N. 2026-05-04 21:32:46 +03:00 committed by GitHub
parent 8d69a7c865
commit 9776a72a6a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 350 additions and 316 deletions

View File

@ -18,7 +18,7 @@ class Root(Resource):
self.tail.clear() self.tail.clear()
self.start = self.lastmark = self.lasttime = time() self.start = self.lastmark = self.lasttime = time()
def getChild(self, request, name): def getChild(self, path, request):
return self return self
def render(self, request): def render(self, request):

View File

@ -185,6 +185,7 @@ jobs = 1 # >1 hides results
extension-pkg-allow-list=[ extension-pkg-allow-list=[
"lxml", "lxml",
] ]
load-plugins = ["pylint_per_file_ignores"]
[tool.pylint."MESSAGES CONTROL"] [tool.pylint."MESSAGES CONTROL"]
enable = [ enable = [
@ -244,15 +245,13 @@ disable = [
"unused-import", "unused-import",
# Ones that we may want to address (fix, ignore per-line or move to "don't want to fix") # Ones that we may want to address (fix, ignore per-line or move to "don't want to fix")
"abstract-method",
"arguments-differ", "arguments-differ",
"arguments-renamed",
"dangerous-default-value",
"keyword-arg-before-vararg", "keyword-arg-before-vararg",
"pointless-statement", ]
"raise-missing-from", # requires `pylint_per_file_ignores` plugin
"unnecessary-dunder-call", per-file-ignores = [
"used-before-assignment", # Extended list of ones that we may want to address, only for tests
"./tests/*:abstract-method,arguments-renamed,dangerous-default-value,pointless-statement,raise-missing-from,unnecessary-dunder-call,used-before-assignment",
] ]
[tool.pytest.ini_options] [tool.pytest.ini_options]

View File

@ -125,7 +125,7 @@ class ContractsManager:
def from_spider(self, spider: Spider, results: TestResult) -> list[Request | None]: def from_spider(self, spider: Spider, results: TestResult) -> list[Request | None]:
requests: list[Request | None] = [] requests: list[Request | None] = []
for method in self.tested_methods_from_spidercls(type(spider)): for method in self.tested_methods_from_spidercls(type(spider)):
bound_method = spider.__getattribute__(method) bound_method = getattr(spider, method)
try: try:
requests.append(self.from_method(bound_method, results)) requests.append(self.from_method(bound_method, results))
except Exception: except Exception:

View File

@ -6,6 +6,7 @@ import ipaddress
import logging import logging
import re import re
from contextlib import suppress from contextlib import suppress
from functools import partial
from io import BytesIO from io import BytesIO
from time import monotonic from time import monotonic
from typing import TYPE_CHECKING, Any, TypedDict, TypeVar, cast from typing import TYPE_CHECKING, Any, TypedDict, TypeVar, cast
@ -547,11 +548,7 @@ class ScrapyAgent:
get_warnsize_msg(expected_size, warnsize, request, expected=True) get_warnsize_msg(expected_size, warnsize, request, expected=True)
) )
def _cancel(_: Any) -> None: d: Deferred[_ResultT] = Deferred(partial(self._cancel, txresponse=txresponse))
# Abort connection immediately.
txresponse._transport._producer.abortConnection()
d: Deferred[_ResultT] = Deferred(_cancel)
txresponse.deliverBody( txresponse.deliverBody(
_ResponseReader( _ResponseReader(
finished=d, finished=d,
@ -570,6 +567,11 @@ class ScrapyAgent:
return d return d
@staticmethod
def _cancel(_: Any, txresponse: TxResponse) -> None:
# Abort connection immediately.
txresponse._transport._producer.abortConnection()
def _cb_bodydone(self, result: _ResultT, url: str) -> Response: def _cb_bodydone(self, result: _ResultT, url: str) -> Response:
headers = self._headers_from_twisted_response(result["txresponse"]) headers = self._headers_from_twisted_response(result["txresponse"])
try: try:
@ -667,17 +669,17 @@ class _ResponseReader(Protocol):
assert hostname is not None assert hostname is not None
_log_ssl_conn_debug_info(hostname, connection) _log_ssl_conn_debug_info(hostname, connection)
def dataReceived(self, bodyBytes: bytes) -> None: def dataReceived(self, data: bytes) -> None:
# This maybe called several times after cancel was called with buffered data. # This maybe called several times after cancel was called with buffered data.
if self._finished.called: if self._finished.called:
return return
assert self.transport assert self.transport
self._bodybuf.write(bodyBytes) self._bodybuf.write(data)
self._bytes_received += len(bodyBytes) self._bytes_received += len(data)
if stop_download := check_stop_download( if stop_download := check_stop_download(
signals.bytes_received, self._crawler, self._request, data=bodyBytes signals.bytes_received, self._crawler, self._request, data=data
): ):
self.transport.stopProducing() self.transport.stopProducing()
self.transport.loseConnection() self.transport.loseConnection()

View File

@ -75,87 +75,82 @@ class DownloaderMiddlewareManager(MiddlewareManager):
download_func: Callable[[Request], Coroutine[Any, Any, Response]], download_func: Callable[[Request], Coroutine[Any, Any, Response]],
request: Request, request: Request,
) -> Response | Request: ) -> Response | Request:
async def process_request(request: Request) -> Response | Request:
for method in self.methods["process_request"]:
method = cast("Callable", method)
if method in self._mw_methods_requiring_spider:
response = await ensure_awaitable(
method(request=request, spider=self._spider),
_warn=global_object_name(method),
)
else:
response = await ensure_awaitable(
method(request=request), _warn=global_object_name(method)
)
if response is not None and not isinstance(
response, (Response, Request)
):
raise _InvalidOutput(
f"Middleware {method.__qualname__} must return None, Response or "
f"Request, got {response.__class__.__name__}"
)
if response:
return response
return await download_func(request)
async def process_response(response: Response | Request) -> Response | Request:
if response is None:
raise TypeError("Received None in process_response")
if isinstance(response, Request):
return response
for method in self.methods["process_response"]:
method = cast("Callable", method)
if method in self._mw_methods_requiring_spider:
response = await ensure_awaitable(
method(request=request, response=response, spider=self._spider),
_warn=global_object_name(method),
)
else:
response = await ensure_awaitable(
method(request=request, response=response),
_warn=global_object_name(method),
)
if not isinstance(response, (Response, Request)):
raise _InvalidOutput(
f"Middleware {method.__qualname__} must return Response or Request, "
f"got {type(response)}"
)
if isinstance(response, Request):
return response
return response
async def process_exception(exception: Exception) -> Response | Request:
for method in self.methods["process_exception"]:
method = cast("Callable", method)
if method in self._mw_methods_requiring_spider:
response = await ensure_awaitable(
method(
request=request, exception=exception, spider=self._spider
),
_warn=global_object_name(method),
)
else:
response = await ensure_awaitable(
method(request=request, exception=exception),
_warn=global_object_name(method),
)
if response is not None and not isinstance(
response, (Response, Request)
):
raise _InvalidOutput(
f"Middleware {method.__qualname__} must return None, Response or "
f"Request, got {type(response)}"
)
if response:
return response
raise exception
try: try:
result: Response | Request = await process_request(request) result: Response | Request = await self._process_request(
request, download_func
)
except Exception as ex: except Exception as ex:
await _defer_sleep_async() await _defer_sleep_async()
# either returns a request or response (which we pass to process_response()) # either returns a request or response (which we pass to process_response())
# or reraises the exception # or reraises the exception
result = await process_exception(ex) result = await self._process_exception(ex, request)
return await process_response(result) return await self._process_response(result, request)
def _handle_mw_method(self, method: Callable, **kwargs: Any) -> Any:
if method in self._mw_methods_requiring_spider:
kwargs["spider"] = self._spider
return method(**kwargs)
async def _process_request(
self,
request: Request,
download_func: Callable[[Request], Coroutine[Any, Any, Response]],
) -> Response | Request:
for method in self.methods["process_request"]:
method = cast("Callable", method)
response = await ensure_awaitable(
self._handle_mw_method(method, request=request),
_warn=global_object_name(method),
)
if response is not None and not isinstance(response, (Response, Request)):
raise _InvalidOutput(
f"Middleware {method.__qualname__} must return None, Response or "
f"Request, got {response.__class__.__name__}"
)
if response:
return response
return await download_func(request)
async def _process_response(
self, response: Response | Request, request: Request
) -> Response | Request:
if response is None:
raise TypeError("Received None in process_response")
if isinstance(response, Request):
return response
for method in self.methods["process_response"]:
method = cast("Callable", method)
response = await ensure_awaitable(
self._handle_mw_method(method, request=request, response=response),
_warn=global_object_name(method),
)
if not isinstance(response, (Response, Request)):
raise _InvalidOutput(
f"Middleware {method.__qualname__} must return Response or Request, "
f"got {type(response)}"
)
if isinstance(response, Request):
return response
return response
async def _process_exception(
self, exception: Exception, request: Request | Response
) -> Response | Request:
for method in self.methods["process_exception"]:
method = cast("Callable", method)
response = await ensure_awaitable(
self._handle_mw_method(method, request=request, exception=exception),
_warn=global_object_name(method),
)
if response is not None and not isinstance(response, (Response, Request)):
raise _InvalidOutput(
f"Middleware {method.__qualname__} must return None, Response or "
f"Request, got {type(response)}"
)
if response:
return response
raise exception

View File

@ -11,6 +11,7 @@ import asyncio
import contextlib import contextlib
import logging import logging
import warnings import warnings
from functools import partial
from time import time from time import time
from traceback import format_exc from traceback import format_exc
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
@ -273,7 +274,7 @@ class ExecutionEngine:
""" """
assert self._start is not None assert self._start is not None
try: try:
item_or_request = await self._start.__anext__() item_or_request = await anext(self._start)
except StopAsyncIteration: except StopAsyncIteration:
self._start = None self._start = None
except Exception as exception: except Exception as exception:
@ -352,6 +353,10 @@ class ExecutionEngine:
or self.scraper.slot.needs_backout() or self.scraper.slot.needs_backout()
) )
def _remove_request(self, _: Any, request: Request) -> None:
assert self._slot
self._slot.remove_request(request)
def _start_scheduled_request(self) -> bool: def _start_scheduled_request(self) -> bool:
assert self._slot is not None # typing assert self._slot is not None # typing
assert self.spider is not None # typing assert self.spider is not None # typing
@ -371,11 +376,7 @@ class ExecutionEngine:
) )
) )
def _remove_request(_: Any) -> None: d2: Deferred[None] = d.addBoth(partial(self._remove_request, request=request))
assert self._slot
self._slot.remove_request(request)
d2: Deferred[None] = d.addBoth(_remove_request)
d2.addErrback( d2.addErrback(
lambda f: logger.info( lambda f: logger.info(
"Error while removing request from slot", "Error while removing request from slot",
@ -612,30 +613,33 @@ class ExecutionEngine:
"Closing spider (%(reason)s)", {"reason": reason}, extra={"spider": spider} "Closing spider (%(reason)s)", {"reason": reason}, extra={"spider": spider}
) )
def log_failure(msg: str) -> None:
logger.error(msg, exc_info=True, extra={"spider": spider}) # noqa: LOG014
try: try:
await self._slot.close() await self._slot.close()
except Exception: except Exception:
log_failure("Slot close failure") logger.error("Slot close failure", exc_info=True, extra={"spider": spider})
try: try:
self.downloader.close() self.downloader.close()
except Exception: except Exception:
log_failure("Downloader close failure") logger.error(
"Downloader close failure", exc_info=True, extra={"spider": spider}
)
try: try:
await self.scraper.close_spider_async() await self.scraper.close_spider_async()
except Exception: except Exception:
log_failure("Scraper close failure") logger.error(
"Scraper close failure", exc_info=True, extra={"spider": spider}
)
if hasattr(self._slot.scheduler, "close"): if hasattr(self._slot.scheduler, "close"):
try: try:
if (d := self._slot.scheduler.close(reason)) is not None: if (d := self._slot.scheduler.close(reason)) is not None:
await maybe_deferred_to_future(d) await maybe_deferred_to_future(d)
except Exception: except Exception:
log_failure("Scheduler close failure") logger.error(
"Scheduler close failure", exc_info=True, extra={"spider": spider}
)
try: try:
await self.signals.send_catch_log_async( await self.signals.send_catch_log_async(
@ -644,7 +648,11 @@ class ExecutionEngine:
reason=reason, reason=reason,
) )
except Exception: except Exception:
log_failure("Error while sending spider_close signal") logger.error(
"Error while sending spider_close signal",
exc_info=True,
extra={"spider": spider},
)
assert self.crawler.stats assert self.crawler.stats
try: try:
@ -661,7 +669,7 @@ class ExecutionEngine:
else: else:
self.crawler.stats.close_spider(reason=reason) self.crawler.stats.close_spider(reason=reason)
except Exception: except Exception:
log_failure("Stats close failure") logger.error("Stats close failure")
logger.info( logger.info(
"Spider closed (%(reason)s)", "Spider closed (%(reason)s)",
@ -675,4 +683,4 @@ class ExecutionEngine:
try: try:
await ensure_awaitable(self._spider_closed_callback(spider)) await ensure_awaitable(self._spider_closed_callback(spider))
except Exception: except Exception:
log_failure("Error running spider_closed_callback") logger.error("Error running spider_closed_callback")

View File

@ -155,16 +155,16 @@ class Stream:
"status": None, "status": None,
} }
def _cancel(_: Any) -> None: self._deferred_response: Deferred[Response] = Deferred(self._cancel)
# Close this stream as gracefully as possible
# If the associated request is initiated we reset this stream
# else we directly call close() method
if self.metadata["request_sent"]:
self.reset_stream(StreamCloseReason.CANCELLED)
else:
self.close(StreamCloseReason.CANCELLED)
self._deferred_response: Deferred[Response] = Deferred(_cancel) def _cancel(self, _: Any) -> None:
# Close this stream as gracefully as possible
# If the associated request is initiated we reset this stream
# else we directly call close() method
if self.metadata["request_sent"]:
self.reset_stream(StreamCloseReason.CANCELLED)
else:
self.close(StreamCloseReason.CANCELLED)
def __repr__(self) -> str: def __repr__(self) -> str:
return f"Stream(id={self.stream_id!r})" return f"Stream(id={self.stream_id!r})"

View File

@ -112,40 +112,59 @@ class SpiderMiddlewareManager(MiddlewareManager):
exception_processor_index: int, exception_processor_index: int,
recover_to: MutableChain[_T] | MutableAsyncChain[_T], recover_to: MutableChain[_T] | MutableAsyncChain[_T],
) -> Iterable[_T] | AsyncIterator[_T]: ) -> Iterable[_T] | AsyncIterator[_T]:
def process_sync(iterable: Iterable[_T]) -> Iterable[_T]:
try:
yield from iterable
except Exception as ex:
exception_result = cast(
"Failure | MutableChain[_T]",
self._process_spider_exception(
response, ex, exception_processor_index
),
)
if isinstance(exception_result, Failure):
raise
assert isinstance(recover_to, MutableChain)
recover_to.extend(exception_result)
async def process_async(iterable: AsyncIterator[_T]) -> AsyncIterator[_T]:
try:
async for r in iterable:
yield r
except Exception as ex:
exception_result = cast(
"Failure | MutableAsyncChain[_T]",
self._process_spider_exception(
response, ex, exception_processor_index
),
)
if isinstance(exception_result, Failure):
raise
assert isinstance(recover_to, MutableAsyncChain)
recover_to.extend(exception_result)
if isinstance(iterable, AsyncIterator): if isinstance(iterable, AsyncIterator):
return process_async(iterable) return self._process_async(
return process_sync(iterable) response,
iterable,
exception_processor_index,
cast("MutableAsyncChain[_T]", recover_to),
)
return self._process_sync(
response,
iterable,
exception_processor_index,
cast("MutableChain[_T]", recover_to),
)
def _process_sync(
self,
response: Response,
iterable: Iterable[_T],
exception_processor_index: int,
recover_to: MutableChain[_T],
) -> Iterable[_T]:
try:
yield from iterable
except Exception as ex:
exception_result = cast(
"Failure | MutableChain[_T]",
self._process_spider_exception(response, ex, exception_processor_index),
)
if isinstance(exception_result, Failure):
raise
assert isinstance(recover_to, MutableChain)
recover_to.extend(exception_result)
async def _process_async(
self,
response: Response,
iterable: AsyncIterator[_T],
exception_processor_index: int,
recover_to: MutableAsyncChain[_T],
) -> AsyncIterator[_T]:
try:
async for r in iterable:
yield r
except Exception as ex:
exception_result = cast(
"Failure | MutableAsyncChain[_T]",
self._process_spider_exception(response, ex, exception_processor_index),
)
if isinstance(exception_result, Failure):
raise
assert isinstance(recover_to, MutableAsyncChain)
recover_to.extend(exception_result)
def _process_spider_exception( def _process_spider_exception(
self, self,
@ -350,25 +369,14 @@ class SpiderMiddlewareManager(MiddlewareManager):
"scrape_response_async() called on a SpiderMiddlewareManager" "scrape_response_async() called on a SpiderMiddlewareManager"
" instance created without a crawler." " instance created without a crawler."
) )
async def process_callback_output(
result: Iterable[_T] | AsyncIterator[_T],
) -> MutableChain[_T] | MutableAsyncChain[_T]:
return await self._process_callback_output(response, result)
def process_spider_exception(
exception: Exception,
) -> MutableChain[_T] | MutableAsyncChain[_T]:
return self._process_spider_exception(response, exception)
try: try:
it: Iterable[_T] | AsyncIterator[_T] = await self._process_spider_input( it: Iterable[_T] | AsyncIterator[_T] = await self._process_spider_input(
scrape_func, response, request scrape_func, response, request
) )
return await process_callback_output(it) return await self._process_callback_output(response, it)
except Exception as ex: except Exception as ex:
await _defer_sleep_async() await _defer_sleep_async()
return process_spider_exception(ex) return self._process_spider_exception(response, ex)
async def process_start( async def process_start(
self, spider: Spider | None = None self, spider: Spider | None = None

View File

@ -7,6 +7,7 @@ import pprint
import signal import signal
import warnings import warnings
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from functools import partial
from typing import TYPE_CHECKING, Any, TypeVar from typing import TYPE_CHECKING, Any, TypeVar
from twisted.internet.defer import Deferred, DeferredList, inlineCallbacks from twisted.internet.defer import Deferred, DeferredList, inlineCallbacks
@ -568,28 +569,30 @@ class AsyncCrawlerRunner(CrawlerRunnerBase):
crawler = self.create_crawler(crawler_or_spidercls) crawler = self.create_crawler(crawler_or_spidercls)
return self._crawl(crawler, *args, **kwargs) return self._crawl(crawler, *args, **kwargs)
async def _crawl_and_track(
self, crawler: Crawler, *args: Any, **kwargs: Any
) -> None:
try:
await crawler.crawl_async(*args, **kwargs)
except Exception:
self.bootstrap_failed = True
raise # re-raise so asyncio still logs it to stderr naturally
def _done(self, task: asyncio.Task[None], crawler: Crawler) -> None:
self._active.discard(task)
self.crawlers.discard(crawler)
self.bootstrap_failed |= not getattr(crawler, "spider", None)
def _crawl(self, crawler: Crawler, *args: Any, **kwargs: Any) -> asyncio.Task[None]: def _crawl(self, crawler: Crawler, *args: Any, **kwargs: Any) -> asyncio.Task[None]:
# At this point the asyncio loop has been installed either by the user # At this point the asyncio loop has been installed either by the user
# or by AsyncCrawlerProcess (but it isn't running yet, so no asyncio.create_task()). # or by AsyncCrawlerProcess (but it isn't running yet, so no asyncio.create_task()).
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
self.crawlers.add(crawler) self.crawlers.add(crawler)
async def _crawl_and_track() -> None: task = loop.create_task(self._crawl_and_track(crawler, *args, **kwargs))
try:
await crawler.crawl_async(*args, **kwargs)
except Exception:
self.bootstrap_failed = True
raise # re-raise so asyncio still logs it to stderr naturally
task = loop.create_task(_crawl_and_track())
self._active.add(task) self._active.add(task)
task.add_done_callback(partial(self._done, crawler=crawler))
def _done(_: asyncio.Task[None]) -> None:
self.crawlers.discard(crawler)
self._active.discard(task)
self.bootstrap_failed |= not getattr(crawler, "spider", None)
task.add_done_callback(_done)
return task return task
async def stop(self) -> None: async def stop(self) -> None:
@ -1007,14 +1010,15 @@ class AsyncCrawlerProcess(CrawlerProcessBase, AsyncCrawlerRunner):
if (loop := self._reactorless_loop) is None: if (loop := self._reactorless_loop) is None:
return return
def _create_shutdown_task() -> None: loop.call_soon_threadsafe(self._create_shutdown_task)
coro = self._shutdown_graceful_reactorless()
try:
loop.create_task(coro)
except RuntimeError:
coro.close()
loop.call_soon_threadsafe(_create_shutdown_task) def _create_shutdown_task(self) -> None:
assert self._reactorless_loop
coro = self._shutdown_graceful_reactorless()
try:
self._reactorless_loop.create_task(coro)
except RuntimeError:
coro.close()
async def _shutdown_graceful_reactorless(self) -> None: async def _shutdown_graceful_reactorless(self) -> None:
await self.stop() await self.stop()

View File

@ -534,13 +534,15 @@ class FeedExporter:
# Send FEED_EXPORTER_CLOSED signal # Send FEED_EXPORTER_CLOSED signal
await self.crawler.signals.send_catch_log_async(signals.feed_exporter_closed) await self.crawler.signals.send_catch_log_async(signals.feed_exporter_closed)
@staticmethod
def _get_file(slot_: FeedSlot) -> IO[bytes]:
assert slot_.file
if isinstance(slot_.file, PostProcessingManager):
slot_.file.close()
return slot_.file.file
return slot_.file
async def _close_slot(self, slot: FeedSlot, spider: Spider) -> None: async def _close_slot(self, slot: FeedSlot, spider: Spider) -> None:
def get_file(slot_: FeedSlot) -> IO[bytes]:
assert slot_.file
if isinstance(slot_.file, PostProcessingManager):
slot_.file.close()
return slot_.file.file
return slot_.file
if slot.itemcount: if slot.itemcount:
# Normal case # Normal case
@ -557,7 +559,7 @@ class FeedExporter:
slot_type = type(slot.storage).__name__ slot_type = type(slot.storage).__name__
assert self.crawler.stats assert self.crawler.stats
try: try:
await ensure_awaitable(slot.storage.store(get_file(slot))) await ensure_awaitable(slot.storage.store(self._get_file(slot)))
except Exception: except Exception:
logger.error( logger.error(
"Error storing %s", "Error storing %s",

View File

@ -37,16 +37,16 @@ class ItemPipelineManager(MiddlewareManager):
settings.get_component_priority_dict_with_base("ITEM_PIPELINES") settings.get_component_priority_dict_with_base("ITEM_PIPELINES")
) )
def _add_middleware(self, pipe: Any) -> None: def _add_middleware(self, mw: Any) -> None:
if hasattr(pipe, "open_spider"): if hasattr(mw, "open_spider"):
self.methods["open_spider"].append(pipe.open_spider) self.methods["open_spider"].append(mw.open_spider)
self._check_mw_method_spider_arg(pipe.open_spider) self._check_mw_method_spider_arg(mw.open_spider)
if hasattr(pipe, "close_spider"): if hasattr(mw, "close_spider"):
self.methods["close_spider"].appendleft(pipe.close_spider) self.methods["close_spider"].appendleft(mw.close_spider)
self._check_mw_method_spider_arg(pipe.close_spider) self._check_mw_method_spider_arg(mw.close_spider)
if hasattr(pipe, "process_item"): if hasattr(mw, "process_item"):
self.methods["process_item"].append(pipe.process_item) self.methods["process_item"].append(mw.process_item)
self._check_mw_method_spider_arg(pipe.process_item) self._check_mw_method_spider_arg(mw.process_item)
def process_item(self, item: Any, spider: Spider) -> Deferred[Any]: def process_item(self, item: Any, spider: Spider) -> Deferred[Any]:
warnings.warn( warnings.warn(
@ -62,32 +62,44 @@ class ItemPipelineManager(MiddlewareManager):
"process_item", item, add_spider=True, warn_deferred=True "process_item", item, add_spider=True, warn_deferred=True
) )
def _get_dfd(
self,
method: Callable[..., Coroutine[Any, Any, None] | Deferred[None] | None],
) -> Deferred[None]:
if method in self._mw_methods_requiring_spider:
return _maybeDeferred_coro(method, True, self._spider)
return _maybeDeferred_coro(method, True)
@staticmethod
def _eb(failure: Failure) -> Failure:
assert isinstance(failure.value, FirstError)
return failure.value.subFailure
def _process_parallel_dfd(self, methodname: str) -> Deferred[list[None]]: def _process_parallel_dfd(self, methodname: str) -> Deferred[list[None]]:
methods = cast( methods = cast(
"Iterable[Callable[..., Coroutine[Any, Any, None] | Deferred[None] | None]]", "Iterable[Callable[..., Coroutine[Any, Any, None] | Deferred[None] | None]]",
self.methods[methodname], self.methods[methodname],
) )
def get_dfd( dfds = [self._get_dfd(m) for m in methods]
method: Callable[..., Coroutine[Any, Any, None] | Deferred[None] | None],
) -> Deferred[None]:
if method in self._mw_methods_requiring_spider:
return _maybeDeferred_coro(method, True, self._spider)
return _maybeDeferred_coro(method, True)
dfds = [get_dfd(m) for m in methods]
d: Deferred[list[tuple[bool, None]]] = DeferredList( d: Deferred[list[tuple[bool, None]]] = DeferredList(
dfds, fireOnOneErrback=True, consumeErrors=True dfds, fireOnOneErrback=True, consumeErrors=True
) )
d2: Deferred[list[None]] = d.addCallback(lambda r: [x[1] for x in r]) d2: Deferred[list[None]] = d.addCallback(lambda r: [x[1] for x in r])
def eb(failure: Failure) -> Failure: d2.addErrback(self._eb)
assert isinstance(failure.value, FirstError)
return failure.value.subFailure
d2.addErrback(eb)
return d2 return d2
def get_awaitable(
self,
method: Callable[..., Coroutine[Any, Any, None] | Deferred[None] | None],
) -> Awaitable[None]:
if method in self._mw_methods_requiring_spider:
result = method(self._spider)
else:
result = method()
return ensure_awaitable(result, _warn=global_object_name(method))
async def _process_parallel_asyncio(self, methodname: str) -> list[None]: async def _process_parallel_asyncio(self, methodname: str) -> list[None]:
methods = cast( methods = cast(
"Iterable[Callable[..., Coroutine[Any, Any, None] | Deferred[None] | None]]", "Iterable[Callable[..., Coroutine[Any, Any, None] | Deferred[None] | None]]",
@ -96,16 +108,7 @@ class ItemPipelineManager(MiddlewareManager):
if not methods: if not methods:
return [] return []
def get_awaitable( awaitables = [self.get_awaitable(m) for m in methods]
method: Callable[..., Coroutine[Any, Any, None] | Deferred[None] | None],
) -> Awaitable[None]:
if method in self._mw_methods_requiring_spider:
result = method(self._spider)
else:
result = method()
return ensure_awaitable(result, _warn=global_object_name(method))
awaitables = [get_awaitable(m) for m in methods]
await asyncio.gather(*awaitables) await asyncio.gather(*awaitables)
return [None for _ in methods] return [None for _ in methods]

View File

@ -186,16 +186,18 @@ class S3FilesStore:
raise ValueError(f"Incorrect URI scheme in {uri}, expected 's3'") raise ValueError(f"Incorrect URI scheme in {uri}, expected 's3'")
self.bucket, self.prefix = uri[5:].split("/", 1) self.bucket, self.prefix = uri[5:].split("/", 1)
@staticmethod
def _onsuccess(boto_key: dict[str, Any]) -> StatInfo:
checksum = boto_key["ETag"].strip('"')
last_modified = boto_key["LastModified"]
modified_stamp = time.mktime(last_modified.timetuple())
return {"checksum": checksum, "last_modified": modified_stamp}
def stat_file( def stat_file(
self, path: str, info: MediaPipeline.SpiderInfo self, path: str, info: MediaPipeline.SpiderInfo
) -> Deferred[StatInfo]: ) -> Deferred[StatInfo]:
def _onsuccess(boto_key: dict[str, Any]) -> StatInfo:
checksum = boto_key["ETag"].strip('"')
last_modified = boto_key["LastModified"]
modified_stamp = time.mktime(last_modified.timetuple())
return {"checksum": checksum, "last_modified": modified_stamp}
return self._get_boto_key(path).addCallback(_onsuccess) return self._get_boto_key(path).addCallback(self._onsuccess)
def _get_boto_key(self, path: str) -> Deferred[dict[str, Any]]: def _get_boto_key(self, path: str) -> Deferred[dict[str, Any]]:
key_name = f"{self.prefix}{path}" key_name = f"{self.prefix}{path}"
@ -308,20 +310,22 @@ class GCSFilesStore:
{"bucket": bucket}, {"bucket": bucket},
) )
@staticmethod
def _onsuccess(blob: Any) -> StatInfo:
if blob:
checksum = base64.b64decode(blob.md5_hash).hex()
last_modified = time.mktime(blob.updated.timetuple())
return {"checksum": checksum, "last_modified": last_modified}
return {}
def stat_file( def stat_file(
self, path: str, info: MediaPipeline.SpiderInfo self, path: str, info: MediaPipeline.SpiderInfo
) -> Deferred[StatInfo]: ) -> Deferred[StatInfo]:
def _onsuccess(blob: Any) -> StatInfo:
if blob:
checksum = base64.b64decode(blob.md5_hash).hex()
last_modified = time.mktime(blob.updated.timetuple())
return {"checksum": checksum, "last_modified": last_modified}
return {}
blob_path = self._get_blob_path(path) blob_path = self._get_blob_path(path)
return deferred_from_coro( return deferred_from_coro(
run_in_thread(self.bucket.get_blob, blob_path) run_in_thread(self.bucket.get_blob, blob_path)
).addCallback(_onsuccess) ).addCallback(self._onsuccess)
def _get_content_type(self, headers: dict[str, str] | None) -> str: def _get_content_type(self, headers: dict[str, str] | None) -> str:
if headers and "Content-Type" in headers: if headers and "Content-Type" in headers:
@ -395,26 +399,26 @@ class FTPFilesStore:
) )
) )
def _stat_file(self, path: str) -> StatInfo:
try:
with FTP() as ftp:
ftp.connect(self.host, self.port)
ftp.login(self.username, self.password)
if self.USE_ACTIVE_MODE:
ftp.set_pasv(False)
file_path = f"{self.basedir}/{path}"
last_modified = float(ftp.voidcmd(f"MDTM {file_path}")[4:].strip())
m = hashlib.md5() # noqa: S324
ftp.retrbinary(f"RETR {file_path}", m.update)
return {"last_modified": last_modified, "checksum": m.hexdigest()}
# The file doesn't exist
except Exception:
return {}
def stat_file( def stat_file(
self, path: str, info: MediaPipeline.SpiderInfo self, path: str, info: MediaPipeline.SpiderInfo
) -> Deferred[StatInfo]: ) -> Deferred[StatInfo]:
def _stat_file(path: str) -> StatInfo: return deferred_from_coro(run_in_thread(self._stat_file, path))
try:
with FTP() as ftp:
ftp.connect(self.host, self.port)
ftp.login(self.username, self.password)
if self.USE_ACTIVE_MODE:
ftp.set_pasv(False)
file_path = f"{self.basedir}/{path}"
last_modified = float(ftp.voidcmd(f"MDTM {file_path}")[4:].strip())
m = hashlib.md5() # noqa: S324
ftp.retrbinary(f"RETR {file_path}", m.update)
return {"last_modified": last_modified, "checksum": m.hexdigest()}
# The file doesn't exist
except Exception:
return {}
return deferred_from_coro(run_in_thread(_stat_file, path))
class FilesPipeline(MediaPipeline): class FilesPipeline(MediaPipeline):
@ -534,43 +538,51 @@ class FilesPipeline(MediaPipeline):
store_cls = self.STORE_SCHEMES[scheme] store_cls = self.STORE_SCHEMES[scheme]
return store_cls(uri) return store_cls(uri)
def _onsuccess(
self,
result: StatInfo,
request: Request,
info: MediaPipeline.SpiderInfo,
path: str,
) -> FileInfo | None:
if not result:
return None # returning None force download
last_modified = result.get("last_modified", None)
if not last_modified:
return None # returning None force download
age_seconds = time.time() - last_modified
age_days = age_seconds / 60 / 60 / 24
if age_days > self.expires:
return None # returning None force download
referer = referer_str(request)
logger.debug(
"File (uptodate): Downloaded %(medianame)s from %(request)s "
"referred in <%(referer)s>",
{"medianame": self.MEDIA_NAME, "request": request, "referer": referer},
extra={"spider": info.spider},
)
self.inc_stats("uptodate")
checksum = result.get("checksum", None)
return {
"url": request.url,
"path": path,
"checksum": checksum,
"status": "uptodate",
}
def media_to_download( def media_to_download(
self, request: Request, info: MediaPipeline.SpiderInfo, *, item: Any = None self, request: Request, info: MediaPipeline.SpiderInfo, *, item: Any = None
) -> Deferred[FileInfo | None] | None: ) -> Deferred[FileInfo | None] | None:
def _onsuccess(result: StatInfo) -> FileInfo | None:
if not result:
return None # returning None force download
last_modified = result.get("last_modified", None)
if not last_modified:
return None # returning None force download
age_seconds = time.time() - last_modified
age_days = age_seconds / 60 / 60 / 24
if age_days > self.expires:
return None # returning None force download
referer = referer_str(request)
logger.debug(
"File (uptodate): Downloaded %(medianame)s from %(request)s "
"referred in <%(referer)s>",
{"medianame": self.MEDIA_NAME, "request": request, "referer": referer},
extra={"spider": info.spider},
)
self.inc_stats("uptodate")
checksum = result.get("checksum", None)
return {
"url": request.url,
"path": path,
"checksum": checksum,
"status": "uptodate",
}
path = self.file_path(request, info=info, item=item) path = self.file_path(request, info=info, item=item)
# maybeDeferred() overloads don't seem to support a Union[_T, Deferred[_T]] return type # maybeDeferred() overloads don't seem to support a Union[_T, Deferred[_T]] return type
dfd: Deferred[StatInfo] = maybeDeferred(self.store.stat_file, path, info) # type: ignore[call-overload] dfd: Deferred[StatInfo] = maybeDeferred(self.store.stat_file, path, info) # type: ignore[call-overload]
dfd2: Deferred[FileInfo | None] = dfd.addCallback(_onsuccess) dfd2: Deferred[FileInfo | None] = dfd.addCallback(
functools.partial(self._onsuccess, request=request, info=info, path=path)
)
dfd2.addErrback(lambda _: None) dfd2.addErrback(lambda _: None)
dfd2.addErrback( dfd2.addErrback(
lambda f: logger.error( lambda f: logger.error(

View File

@ -143,7 +143,7 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]):
raise ValueError(f"{item!r} not found in the {name} setting ({value!r}).") raise ValueError(f"{item!r} not found in the {name} setting ({value!r}).")
self.set(name, [v for v in value if v != item], self.getpriority(name) or 0) self.set(name, [v for v in value if v != item], self.getpriority(name) or 0)
def get(self, name: _SettingsKey, default: Any = None) -> Any: def get(self, name: _SettingsKey, default: Any = None) -> Any: # pylint: disable=arguments-renamed
""" """
Get a setting value without affecting its original type. Get a setting value without affecting its original type.
@ -510,7 +510,7 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]):
component_priority_dict[cls] = priority component_priority_dict[cls] = priority
self.set(name, component_priority_dict, self.getpriority(name) or 0) self.set(name, component_priority_dict, self.getpriority(name) or 0)
def setdefault( def setdefault( # pylint: disable=arguments-renamed
self, self,
name: _SettingsKey, name: _SettingsKey,
default: Any = None, default: Any = None,
@ -691,14 +691,14 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]):
else: else:
p.text(pformat(self.copy_to_dict())) p.text(pformat(self.copy_to_dict()))
def pop(self, name: _SettingsKey, default: Any = __default) -> Any: def pop(self, name: _SettingsKey, default: Any = __default) -> Any: # pylint: disable=arguments-renamed
try: try:
value = self.attributes[name].value value = self.attributes[name].value
except KeyError: except KeyError:
if default is self.__default: if default is self.__default:
raise raise
return default return default
self.__delitem__(name) del self[name]
return value return value

View File

@ -9,7 +9,7 @@ from twisted.web.server import Request, Site
class Root(Resource): class Root(Resource):
isLeaf = True isLeaf = True
def getChild(self, name: str, request: Request) -> Resource: def getChild(self, path: str, request: Request) -> Resource:
return self return self
def render(self, request: Request) -> bytes: def render(self, request: Request) -> bytes:

View File

@ -262,7 +262,7 @@ class _AsyncCooperatorAdapter(Iterator, Generic[_T]):
def _call_anext(self) -> None: def _call_anext(self) -> None:
# This starts waiting for the next result from aiterator. # This starts waiting for the next result from aiterator.
# If aiterator is exhausted, _errback will be called. # If aiterator is exhausted, _errback will be called.
self.anext_deferred = deferred_from_coro(self.aiterator.__anext__()) self.anext_deferred = deferred_from_coro(anext(self.aiterator))
self.anext_deferred.addCallbacks(self._callback, self._errback) self.anext_deferred.addCallbacks(self._callback, self._errback)
def __next__(self) -> Deferred[Any]: def __next__(self) -> Deferred[Any]:
@ -370,10 +370,10 @@ async def aiter_errback(
"""Wrap an async iterable calling an errback if an error is caught while """Wrap an async iterable calling an errback if an error is caught while
iterating it. Similar to :func:`scrapy.utils.defer.iter_errback`. iterating it. Similar to :func:`scrapy.utils.defer.iter_errback`.
""" """
it = aiterable.__aiter__() it = aiter(aiterable)
while True: while True:
try: try:
yield await it.__anext__() yield await anext(it)
except StopAsyncIteration: except StopAsyncIteration:
break break
except Exception: except Exception:

View File

@ -255,6 +255,11 @@ def walk_callable(node: ast.AST) -> Iterable[ast.AST]:
_generator_callbacks_cache = LocalWeakReferencedCache(limit=128) _generator_callbacks_cache = LocalWeakReferencedCache(limit=128)
def _returns_none(return_node: ast.Return) -> bool:
value = return_node.value
return value is None or (isinstance(value, ast.Constant) and value.value is None)
def is_generator_with_return_value(callable: Callable[..., Any]) -> bool: # noqa: A002 def is_generator_with_return_value(callable: Callable[..., Any]) -> bool: # noqa: A002
""" """
Returns True if a callable is a generator function which includes a Returns True if a callable is a generator function which includes a
@ -263,12 +268,6 @@ def is_generator_with_return_value(callable: Callable[..., Any]) -> bool: # noq
if callable in _generator_callbacks_cache: if callable in _generator_callbacks_cache:
return bool(_generator_callbacks_cache[callable]) return bool(_generator_callbacks_cache[callable])
def returns_none(return_node: ast.Return) -> bool:
value = return_node.value
return value is None or (
isinstance(value, ast.Constant) and value.value is None
)
if inspect.isgeneratorfunction(callable): if inspect.isgeneratorfunction(callable):
func = callable func = callable
while isinstance(func, partial): while isinstance(func, partial):
@ -284,7 +283,7 @@ def is_generator_with_return_value(callable: Callable[..., Any]) -> bool: # noq
tree = ast.parse(code) tree = ast.parse(code)
for node in walk_callable(tree): for node in walk_callable(tree):
if isinstance(node, ast.Return) and not returns_none(node): if isinstance(node, ast.Return) and not _returns_none(node):
_generator_callbacks_cache[callable] = True _generator_callbacks_cache[callable] = True
return bool(_generator_callbacks_cache[callable]) return bool(_generator_callbacks_cache[callable])

View File

@ -99,6 +99,16 @@ def to_bytes(
return text.encode(encoding, errors) return text.encode(encoding, errors)
def _chunk_iter(text: str, chunk_size: int) -> Iterable[tuple[str, int]]:
offset = len(text)
while True:
offset -= chunk_size * 1024
if offset <= 0:
break
yield (text[offset:], offset)
yield (text, 0)
def re_rsearch( def re_rsearch(
pattern: str | Pattern[str], text: str, chunk_size: int = 1024 pattern: str | Pattern[str], text: str, chunk_size: int = 1024
) -> tuple[int, int] | None: ) -> tuple[int, int] | None:
@ -115,19 +125,10 @@ def re_rsearch(
the start position of the match, and the ending (regarding the entire text). the start position of the match, and the ending (regarding the entire text).
""" """
def _chunk_iter() -> Iterable[tuple[str, int]]:
offset = len(text)
while True:
offset -= chunk_size * 1024
if offset <= 0:
break
yield (text[offset:], offset)
yield (text, 0)
if isinstance(pattern, str): if isinstance(pattern, str):
pattern = re.compile(pattern) pattern = re.compile(pattern)
for chunk, offset in _chunk_iter(): for chunk, offset in _chunk_iter(text, chunk_size):
matches = list(pattern.finditer(chunk)) matches = list(pattern.finditer(chunk))
if matches: if matches:
start, end = matches[-1].span() start, end = matches[-1].span()

View File

@ -85,7 +85,7 @@ class Root(resource.Resource):
self.putChild(b"response-headers", ResponseHeadersResource()) self.putChild(b"response-headers", ResponseHeadersResource())
self.putChild(b"set-cookie", SetCookie()) self.putChild(b"set-cookie", SetCookie())
def getChild(self, name, request): def getChild(self, path, request):
return self return self
def render(self, request): def render(self, request):

View File

@ -13,7 +13,7 @@ class Root(resource.Resource):
resource.Resource.__init__(self) resource.Resource.__init__(self)
self.putChild(b"file", Data(b"0123456789", "text/plain")) self.putChild(b"file", Data(b"0123456789", "text/plain"))
def getChild(self, name, request): def getChild(self, path, request):
return self return self

View File

@ -89,8 +89,9 @@ basepython = python3
deps = deps =
{[testenv:extra-deps]deps} {[testenv:extra-deps]deps}
pylint==4.0.2 pylint==4.0.2
pylint-per-file-ignores # https://github.com/pylint-dev/pylint/issues/3767#issuecomment-1319916278
commands = commands =
pylint conftest.py docs extras scrapy tests pylint {posargs:conftest.py docs extras scrapy tests}
[testenv:twinecheck] [testenv:twinecheck]
basepython = python3 basepython = python3