mirror of https://github.com/scrapy/scrapy.git
Merge remote-tracking branch 'origin/master' into async-s3
This commit is contained in:
commit
0cab83e16a
|
|
@ -564,6 +564,10 @@ defines the methods described below.
|
|||
|
||||
Return response if present in cache, or ``None`` otherwise.
|
||||
|
||||
If this method raises an exception, e.g. because the cache entry is
|
||||
corrupted, the middleware logs a warning and handles the request as a
|
||||
cache miss.
|
||||
|
||||
:param spider: the spider which generated the request
|
||||
:type spider: :class:`~scrapy.Spider` object
|
||||
|
||||
|
|
|
|||
|
|
@ -533,8 +533,7 @@ Here are some tips to keep in mind when dealing with these kinds of sites:
|
|||
* if possible, use `Common Crawl`_ to fetch pages, instead of hitting the sites
|
||||
directly
|
||||
* use a pool of rotating IPs. For example, the free `Tor project`_ or paid
|
||||
services like `ProxyMesh`_. An open source alternative is `scrapoxy`_, a
|
||||
super proxy that you can attach your own proxies to.
|
||||
services like `ProxyMesh`_.
|
||||
* for HTTPS websites, if blocking appears related to TLS behavior, consider
|
||||
adjusting the :setting:`DOWNLOAD_TLS_MIN_VERSION` and
|
||||
:setting:`DOWNLOAD_TLS_MAX_VERSION` settings, since some websites may respond
|
||||
|
|
@ -559,5 +558,4 @@ projects that detects common mistakes and anti-patterns.
|
|||
.. _ProxyMesh: https://proxymesh.com/
|
||||
.. _Common Crawl: https://commoncrawl.org/
|
||||
.. _testspiders: https://github.com/scrapinghub/testspiders
|
||||
.. _scrapoxy: https://scrapoxy.io/
|
||||
.. _Zyte API: https://docs.zyte.com/zyte-api/get-started.html
|
||||
|
|
|
|||
|
|
@ -260,6 +260,8 @@ Request objects
|
|||
|
||||
.. automethod:: from_curl
|
||||
|
||||
.. automethod:: to_curl
|
||||
|
||||
.. automethod:: to_dict
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -178,13 +178,6 @@ module = [
|
|||
"tests.test_squeues",
|
||||
"tests.test_squeues_request",
|
||||
"tests.test_stats",
|
||||
"tests.test_utils_datatypes",
|
||||
"tests.test_utils_decorators",
|
||||
"tests.test_utils_defer",
|
||||
"tests.test_utils_deprecate",
|
||||
"tests.test_utils_misc.test_return_with_argument_inside_generator",
|
||||
"tests.test_utils_python",
|
||||
"tests.test_utils_request",
|
||||
"tests.utils.bases.http_request",
|
||||
"tests.utils.bases.http_response",
|
||||
"tests.utils.bases.spider",
|
||||
|
|
|
|||
|
|
@ -225,13 +225,11 @@ def _run_command(cmd: ScrapyCommand, args: list[str], opts: argparse.Namespace)
|
|||
def _run_command_profiled(
|
||||
cmd: ScrapyCommand, args: list[str], opts: argparse.Namespace
|
||||
) -> None:
|
||||
if opts.profile:
|
||||
sys.stderr.write(f"scrapy: writing cProfile stats to {opts.profile!r}\n")
|
||||
sys.stderr.write(f"scrapy: writing cProfile stats to {opts.profile!r}\n")
|
||||
loc = locals()
|
||||
p = cProfile.Profile()
|
||||
p.runctx("cmd.run(args, opts)", globals(), loc)
|
||||
if opts.profile:
|
||||
p.dump_stats(opts.profile)
|
||||
p.dump_stats(opts.profile)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -346,6 +346,8 @@ class Command(BaseRunSpiderCommand):
|
|||
self.first_response = response
|
||||
|
||||
cb = self._get_callback(spider=spider, opts=opts, response=response)
|
||||
assert response.request
|
||||
response.request.callback = cb
|
||||
|
||||
# parse items and requests
|
||||
depth: int = response.meta["_depth"]
|
||||
|
|
|
|||
|
|
@ -126,5 +126,4 @@ class FTPDownloadHandler(BaseDownloadHandler):
|
|||
headers = {"local filename": protocol.filename or b"", "size": protocol.size}
|
||||
body = protocol.filename or protocol.body.read()
|
||||
respcls = responsetypes.from_args(url=request.url, body=body)
|
||||
# hints for Headers-related types may need to be fixed to not use AnyStr
|
||||
return respcls(url=request.url, status=200, body=body, headers=headers) # type: ignore[arg-type]
|
||||
return respcls(url=request.url, status=200, body=body, headers=headers)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from email.utils import formatdate
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
|
|
@ -28,6 +29,9 @@ if TYPE_CHECKING:
|
|||
from scrapy.statscollectors import StatsCollector
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HttpCacheMiddleware:
|
||||
DOWNLOAD_EXCEPTIONS = (
|
||||
ConnectionDone,
|
||||
|
|
@ -77,9 +81,20 @@ class HttpCacheMiddleware:
|
|||
return None
|
||||
|
||||
# Look for cached response and check if expired
|
||||
cachedresponse: Response | None = self.storage.retrieve_response(
|
||||
self.crawler.spider, request
|
||||
)
|
||||
cachedresponse: Response | None
|
||||
try:
|
||||
cachedresponse = self.storage.retrieve_response(
|
||||
self.crawler.spider, request
|
||||
)
|
||||
except Exception:
|
||||
self.stats.inc_value("httpcache/retrieve_error")
|
||||
logger.warning(
|
||||
f"Could not read the cache entry for {request}, treating it as a "
|
||||
f"cache miss.",
|
||||
exc_info=True,
|
||||
extra={"spider": self.crawler.spider},
|
||||
)
|
||||
cachedresponse = None
|
||||
if cachedresponse is None:
|
||||
self.stats.inc_value("httpcache/miss")
|
||||
if self.ignore_missing:
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import re
|
|||
import sys
|
||||
import warnings
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Callable, Coroutine
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path, PureWindowsPath
|
||||
from tempfile import NamedTemporaryFile
|
||||
|
|
@ -501,7 +501,7 @@ class FeedExporter:
|
|||
self.feeds = {}
|
||||
self.slots: list[FeedSlot] = []
|
||||
self.filters: dict[str, ItemFilter] = {}
|
||||
self._pending_close_coros: list[Coroutine[Any, Any, None]] = []
|
||||
self._pending_close_tasks: list[asyncio.Task[None] | Deferred[None]] = []
|
||||
|
||||
if not self.settings["FEEDS"] and not self.settings["FEED_URI"]:
|
||||
raise NotConfigured
|
||||
|
|
@ -565,23 +565,44 @@ class FeedExporter:
|
|||
)
|
||||
|
||||
async def close_spider(self, spider: Spider) -> None:
|
||||
self._pending_close_coros.extend(
|
||||
self._close_slot(slot, spider) for slot in self.slots
|
||||
)
|
||||
for slot in self.slots:
|
||||
self._schedule_slot_close(slot, spider)
|
||||
|
||||
if self._pending_close_coros:
|
||||
if self._pending_close_tasks:
|
||||
if is_asyncio_available():
|
||||
await asyncio.wait(
|
||||
[asyncio.create_task(coro) for coro in self._pending_close_coros]
|
||||
cast("list[asyncio.Task[None]]", list(self._pending_close_tasks))
|
||||
)
|
||||
else:
|
||||
await DeferredList(
|
||||
deferred_from_coro(coro) for coro in self._pending_close_coros
|
||||
cast("list[Deferred[None]]", list(self._pending_close_tasks))
|
||||
)
|
||||
|
||||
# Send FEED_EXPORTER_CLOSED signal
|
||||
await self.crawler.signals.send_catch_log_async(signals.feed_exporter_closed)
|
||||
|
||||
def _schedule_slot_close(
|
||||
self, slot: FeedSlot, spider: Spider
|
||||
) -> asyncio.Task[None] | Deferred[None]:
|
||||
"""Start closing the slot without waiting for it to finish, keeping
|
||||
track of the pending work so that it can be awaited in
|
||||
:meth:`close_spider` if it hasn't finished by then."""
|
||||
aw: asyncio.Task[None] | Deferred[None]
|
||||
coro = self._close_slot(slot, spider)
|
||||
if is_asyncio_available():
|
||||
aw = asyncio.create_task(coro)
|
||||
self._pending_close_tasks.append(aw)
|
||||
aw.add_done_callback(self._pending_close_tasks.remove)
|
||||
else:
|
||||
aw = deferred_from_coro(coro)
|
||||
self._pending_close_tasks.append(aw)
|
||||
aw.addBoth(self._untrack_pending_close_task, aw)
|
||||
return aw
|
||||
|
||||
def _untrack_pending_close_task(self, result: Any, aw: Deferred[None]) -> Any:
|
||||
self._pending_close_tasks.remove(aw)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _get_file(slot_: FeedSlot) -> IO[bytes]:
|
||||
assert slot_.file
|
||||
|
|
@ -678,7 +699,7 @@ class FeedExporter:
|
|||
uri_params = self._get_uri_params(
|
||||
spider, self.feeds[slot.uri_template]["uri_params"], slot
|
||||
)
|
||||
self._pending_close_coros.append(self._close_slot(slot, spider))
|
||||
self._schedule_slot_close(slot, spider)
|
||||
slots.append(
|
||||
self._start_new_batch(
|
||||
batch_id=slot.batch_id + 1,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import TYPE_CHECKING, Any, AnyStr, TypeAlias, cast
|
||||
from typing import TYPE_CHECKING, Any, TypeAlias, cast
|
||||
|
||||
from w3lib.http import headers_dict_to_raw
|
||||
|
||||
|
|
@ -25,14 +25,20 @@ class Headers(CaselessDict):
|
|||
|
||||
def __init__(
|
||||
self,
|
||||
seq: Mapping[AnyStr, Any] | Iterable[tuple[AnyStr, Any]] | None = None,
|
||||
seq: Mapping[str, Any]
|
||||
| Mapping[bytes, Any]
|
||||
| Iterable[tuple[str | bytes, Any]]
|
||||
| None = None,
|
||||
encoding: str = "utf-8",
|
||||
):
|
||||
self.encoding: str = encoding
|
||||
super().__init__(seq)
|
||||
|
||||
def update( # type: ignore[override]
|
||||
self, seq: Mapping[AnyStr, Any] | Iterable[tuple[AnyStr, Any]]
|
||||
self,
|
||||
seq: Mapping[str, Any]
|
||||
| Mapping[bytes, Any]
|
||||
| Iterable[tuple[str | bytes, Any]],
|
||||
) -> None:
|
||||
seq = seq.items() if isinstance(seq, Mapping) else seq
|
||||
iseq: dict[bytes, list[bytes]] = {}
|
||||
|
|
@ -40,7 +46,7 @@ class Headers(CaselessDict):
|
|||
iseq.setdefault(self.normkey(k), []).extend(self.normvalue(v))
|
||||
super().update(iseq)
|
||||
|
||||
def normkey(self, key: AnyStr) -> bytes: # type: ignore[override]
|
||||
def normkey(self, key: str | bytes) -> bytes:
|
||||
"""Normalize key to bytes"""
|
||||
return self._tobytes(key.title())
|
||||
|
||||
|
|
@ -67,19 +73,19 @@ class Headers(CaselessDict):
|
|||
return str(x).encode(self.encoding)
|
||||
raise TypeError(f"Unsupported value type: {type(x)}")
|
||||
|
||||
def __getitem__(self, key: AnyStr) -> bytes | None:
|
||||
def __getitem__(self, key: str | bytes) -> bytes | None:
|
||||
try:
|
||||
return cast("list[bytes]", super().__getitem__(key))[-1]
|
||||
except IndexError:
|
||||
return None
|
||||
|
||||
def get(self, key: AnyStr, def_val: Any = None) -> bytes | None:
|
||||
def get(self, key: str | bytes, def_val: Any = None) -> bytes | None:
|
||||
try:
|
||||
return cast("list[bytes]", super().get(key, def_val))[-1]
|
||||
except IndexError:
|
||||
return None
|
||||
|
||||
def getlist(self, key: AnyStr, def_val: Any = None) -> list[bytes]:
|
||||
def getlist(self, key: str | bytes, def_val: Any = None) -> list[bytes]:
|
||||
try:
|
||||
return cast("list[bytes]", super().__getitem__(key))
|
||||
except KeyError:
|
||||
|
|
@ -87,15 +93,15 @@ class Headers(CaselessDict):
|
|||
return self.normvalue(def_val)
|
||||
return []
|
||||
|
||||
def setlist(self, key: AnyStr, list_: Iterable[_RawValue]) -> None:
|
||||
def setlist(self, key: str | bytes, list_: Iterable[_RawValue]) -> None:
|
||||
self[key] = list_
|
||||
|
||||
def setlistdefault(
|
||||
self, key: AnyStr, default_list: Iterable[_RawValue] = ()
|
||||
self, key: str | bytes, default_list: Iterable[_RawValue] = ()
|
||||
) -> Any:
|
||||
return self.setdefault(key, default_list)
|
||||
|
||||
def appendlist(self, key: AnyStr, value: Iterable[_RawValue]) -> None:
|
||||
def appendlist(self, key: str | bytes, value: Iterable[_RawValue]) -> None:
|
||||
lst = self.getlist(key)
|
||||
lst.extend(self.normvalue(value))
|
||||
self[key] = lst
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import inspect
|
|||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
AnyStr,
|
||||
Concatenate,
|
||||
NoReturn,
|
||||
TypeAlias,
|
||||
|
|
@ -125,7 +124,10 @@ class Request(object_ref):
|
|||
url: str,
|
||||
callback: CallbackT | None = None,
|
||||
method: str = "GET",
|
||||
headers: Mapping[AnyStr, Any] | Iterable[tuple[AnyStr, Any]] | None = None,
|
||||
headers: Mapping[str, Any]
|
||||
| Mapping[bytes, Any]
|
||||
| Iterable[tuple[str | bytes, Any]]
|
||||
| None = None,
|
||||
body: bytes | str | None = None,
|
||||
cookies: CookiesT | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
|
|
@ -310,7 +312,11 @@ class Request(object_ref):
|
|||
|
||||
@headers.setter
|
||||
def headers(
|
||||
self, value: Mapping[AnyStr, Any] | Iterable[tuple[AnyStr, Any]] | None
|
||||
self,
|
||||
value: Mapping[str, Any]
|
||||
| Mapping[bytes, Any]
|
||||
| Iterable[tuple[str | bytes, Any]]
|
||||
| None,
|
||||
) -> None:
|
||||
if isinstance(value, Headers):
|
||||
self._headers = value
|
||||
|
|
@ -381,6 +387,20 @@ class Request(object_ref):
|
|||
request_kwargs.update(kwargs)
|
||||
return cls(**request_kwargs)
|
||||
|
||||
def to_curl(self) -> str:
|
||||
"""Return a string with a `cURL <https://curl.se/>`_ command equivalent
|
||||
to this request.
|
||||
|
||||
Inverse of :meth:`from_curl`. See also
|
||||
:func:`scrapy.utils.request.request_to_curl`.
|
||||
|
||||
.. versionadded:: VERSION
|
||||
"""
|
||||
# Imported here to avoid a circular import.
|
||||
from scrapy.utils.request import request_to_curl # noqa: PLC0415
|
||||
|
||||
return request_to_curl(self)
|
||||
|
||||
def to_dict(self, *, spider: scrapy.Spider | None = None) -> dict[str, Any]:
|
||||
"""Return a dictionary containing the Request's data.
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ See documentation in docs/topics/request-response.rst
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, AnyStr, TypeVar, overload
|
||||
from typing import TYPE_CHECKING, Any, TypeVar, overload
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from scrapy.exceptions import NotSupported
|
||||
|
|
@ -72,7 +72,10 @@ class Response(object_ref):
|
|||
self,
|
||||
url: str,
|
||||
status: int = 200,
|
||||
headers: Mapping[AnyStr, Any] | Iterable[tuple[AnyStr, Any]] | None = None,
|
||||
headers: Mapping[str, Any]
|
||||
| Mapping[bytes, Any]
|
||||
| Iterable[tuple[str | bytes, Any]]
|
||||
| None = None,
|
||||
body: bytes = b"",
|
||||
flags: list[str] | None = None,
|
||||
request: Request | None = None,
|
||||
|
|
@ -145,7 +148,11 @@ class Response(object_ref):
|
|||
|
||||
@headers.setter
|
||||
def headers(
|
||||
self, value: Mapping[AnyStr, Any] | Iterable[tuple[AnyStr, Any]] | None
|
||||
self,
|
||||
value: Mapping[str, Any]
|
||||
| Mapping[bytes, Any]
|
||||
| Iterable[tuple[str | bytes, Any]]
|
||||
| None,
|
||||
) -> None:
|
||||
if isinstance(value, Headers):
|
||||
self._headers = value
|
||||
|
|
@ -222,7 +229,10 @@ class Response(object_ref):
|
|||
url: str | Link,
|
||||
callback: CallbackT | None = None,
|
||||
method: str = "GET",
|
||||
headers: Mapping[AnyStr, Any] | Iterable[tuple[AnyStr, Any]] | None = None,
|
||||
headers: Mapping[str, Any]
|
||||
| Mapping[bytes, Any]
|
||||
| Iterable[tuple[str | bytes, Any]]
|
||||
| None = None,
|
||||
body: bytes | str | None = None,
|
||||
cookies: CookiesT | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
|
|
@ -272,7 +282,10 @@ class Response(object_ref):
|
|||
urls: Iterable[str | Link],
|
||||
callback: CallbackT | None = None,
|
||||
method: str = "GET",
|
||||
headers: Mapping[AnyStr, Any] | Iterable[tuple[AnyStr, Any]] | None = None,
|
||||
headers: Mapping[str, Any]
|
||||
| Mapping[bytes, Any]
|
||||
| Iterable[tuple[str | bytes, Any]]
|
||||
| None = None,
|
||||
body: bytes | str | None = None,
|
||||
cookies: CookiesT | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
from contextlib import suppress
|
||||
from typing import TYPE_CHECKING, Any, AnyStr, cast
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import parsel
|
||||
|
|
@ -170,7 +170,10 @@ class TextResponse(Response):
|
|||
url: str | Link | parsel.Selector,
|
||||
callback: CallbackT | None = None,
|
||||
method: str = "GET",
|
||||
headers: Mapping[AnyStr, Any] | Iterable[tuple[AnyStr, Any]] | None = None,
|
||||
headers: Mapping[str, Any]
|
||||
| Mapping[bytes, Any]
|
||||
| Iterable[tuple[str | bytes, Any]]
|
||||
| None = None,
|
||||
body: bytes | str | None = None,
|
||||
cookies: CookiesT | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
|
|
@ -223,7 +226,10 @@ class TextResponse(Response):
|
|||
urls: Iterable[str | Link] | parsel.SelectorList[Any] | None = None,
|
||||
callback: CallbackT | None = None,
|
||||
method: str = "GET",
|
||||
headers: Mapping[AnyStr, Any] | Iterable[tuple[AnyStr, Any]] | None = None,
|
||||
headers: Mapping[str, Any]
|
||||
| Mapping[bytes, Any]
|
||||
| Iterable[tuple[str | bytes, Any]]
|
||||
| None = None,
|
||||
body: bytes | str | None = None,
|
||||
cookies: CookiesT | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
Mail sending helpers
|
||||
"""
|
||||
|
||||
# pragma: no file cover
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from __future__ import annotations
|
|||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from scrapy.exceptions import NotConfigured, NotSupported
|
||||
from scrapy.exceptions import NotSupported
|
||||
from scrapy.http import Response, TextResponse
|
||||
from scrapy.selector import Selector
|
||||
from scrapy.spiders import Spider
|
||||
|
|
@ -76,11 +76,6 @@ class XMLFeedSpider(Spider):
|
|||
yield from self.process_results(response, ret)
|
||||
|
||||
def _parse(self, response: Response, **kwargs: Any) -> Any:
|
||||
if not hasattr(self, "parse_node"):
|
||||
raise NotConfigured(
|
||||
"You must define parse_node method in order to scrape this XML feed"
|
||||
)
|
||||
|
||||
response = self.adapt_response(response)
|
||||
nodes: Iterable[Selector]
|
||||
if self.iterator == "iternodes":
|
||||
|
|
@ -158,9 +153,5 @@ class CSVFeedSpider(Spider):
|
|||
yield from self.process_results(response, ret)
|
||||
|
||||
def _parse(self, response: Response, **kwargs: Any) -> Any:
|
||||
if not hasattr(self, "parse_row"):
|
||||
raise NotConfigured(
|
||||
"You must define parse_row method in order to scrape this CSV feed"
|
||||
)
|
||||
response = self.adapt_response(response)
|
||||
return self.parse_rows(response)
|
||||
|
|
|
|||
|
|
@ -11,12 +11,12 @@ import warnings
|
|||
import weakref
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Mapping
|
||||
from typing import TYPE_CHECKING, Any, AnyStr, TypeVar, cast
|
||||
from typing import TYPE_CHECKING, Any, TypeVar, cast
|
||||
|
||||
from scrapy.exceptions import ScrapyDeprecationWarning
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable, Sequence
|
||||
from collections.abc import Container, Iterable
|
||||
|
||||
# typing.Self requires Python 3.11
|
||||
from typing_extensions import Self
|
||||
|
|
@ -44,22 +44,25 @@ class CaselessDict(dict): # type: ignore[type-arg]
|
|||
|
||||
def __init__(
|
||||
self,
|
||||
seq: Mapping[AnyStr, Any] | Iterable[tuple[AnyStr, Any]] | None = None,
|
||||
seq: Mapping[str, Any]
|
||||
| Mapping[bytes, Any]
|
||||
| Iterable[tuple[str | bytes, Any]]
|
||||
| None = None,
|
||||
):
|
||||
super().__init__()
|
||||
if seq:
|
||||
self.update(seq)
|
||||
|
||||
def __getitem__(self, key: AnyStr) -> Any:
|
||||
def __getitem__(self, key: str | bytes) -> Any:
|
||||
return dict.__getitem__(self, self.normkey(key))
|
||||
|
||||
def __setitem__(self, key: AnyStr, value: Any) -> None:
|
||||
def __setitem__(self, key: str | bytes, value: Any) -> None:
|
||||
dict.__setitem__(self, self.normkey(key), self.normvalue(value))
|
||||
|
||||
def __delitem__(self, key: AnyStr) -> None:
|
||||
def __delitem__(self, key: str | bytes) -> None:
|
||||
dict.__delitem__(self, self.normkey(key))
|
||||
|
||||
def __contains__(self, key: AnyStr) -> bool: # type: ignore[override]
|
||||
def __contains__(self, key: str | bytes) -> bool: # type: ignore[override]
|
||||
return dict.__contains__(self, self.normkey(key))
|
||||
|
||||
has_key = __contains__
|
||||
|
|
@ -69,7 +72,7 @@ class CaselessDict(dict): # type: ignore[type-arg]
|
|||
|
||||
copy = __copy__
|
||||
|
||||
def normkey(self, key: AnyStr) -> AnyStr:
|
||||
def normkey(self, key: str | bytes) -> str | bytes:
|
||||
"""Method to normalize dictionary key access"""
|
||||
return key.lower()
|
||||
|
||||
|
|
@ -77,23 +80,28 @@ class CaselessDict(dict): # type: ignore[type-arg]
|
|||
"""Method to normalize values prior to be set"""
|
||||
return value
|
||||
|
||||
def get(self, key: AnyStr, def_val: Any = None) -> Any:
|
||||
def get(self, key: str | bytes, def_val: Any = None) -> Any:
|
||||
return dict.get(self, self.normkey(key), self.normvalue(def_val))
|
||||
|
||||
def setdefault(self, key: AnyStr, def_val: Any = None) -> Any:
|
||||
def setdefault(self, key: str | bytes, def_val: Any = None) -> Any:
|
||||
return dict.setdefault(self, self.normkey(key), self.normvalue(def_val))
|
||||
|
||||
# doesn't fully implement MutableMapping.update()
|
||||
def update(self, seq: Mapping[AnyStr, Any] | Iterable[tuple[AnyStr, Any]]) -> None: # type: ignore[override]
|
||||
def update( # type: ignore[override]
|
||||
self,
|
||||
seq: Mapping[str, Any]
|
||||
| Mapping[bytes, Any]
|
||||
| Iterable[tuple[str | bytes, Any]],
|
||||
) -> None:
|
||||
seq = seq.items() if isinstance(seq, Mapping) else seq
|
||||
iseq = ((self.normkey(k), self.normvalue(v)) for k, v in seq)
|
||||
super().update(iseq)
|
||||
|
||||
@classmethod
|
||||
def fromkeys(cls, keys: Iterable[AnyStr], value: Any = None) -> Self: # type: ignore[override]
|
||||
return cls((k, value) for k in keys) # type: ignore[misc]
|
||||
def fromkeys(cls, keys: Iterable[str | bytes], value: Any = None) -> Self: # type: ignore[override]
|
||||
return cls((k, value) for k in keys)
|
||||
|
||||
def pop(self, key: AnyStr, *args: Any) -> Any:
|
||||
def pop(self, key: str | bytes, *args: Any) -> Any:
|
||||
return dict.pop(self, self.normkey(key), *args)
|
||||
|
||||
|
||||
|
|
@ -132,6 +140,22 @@ class CaseInsensitiveDict(collections.UserDict[str | bytes, Any]):
|
|||
def __repr__(self) -> str:
|
||||
return f"<{self.__class__.__name__}: {super().__repr__()}>"
|
||||
|
||||
# UserDict.copy() shallow-copies the instance, which would share self._keys
|
||||
# between the copy and the original.
|
||||
def __copy__(self) -> Self:
|
||||
new = self.__class__()
|
||||
new.data = self.data.copy()
|
||||
new._keys = self._keys.copy()
|
||||
return new
|
||||
|
||||
copy = __copy__
|
||||
|
||||
# UserDict.__ior__ updates self.data directly, which would leave self._keys
|
||||
# out of date.
|
||||
def __ior__(self, other: Any) -> Self: # type: ignore[override,misc]
|
||||
self.update(other)
|
||||
return self
|
||||
|
||||
def _normkey(self, key: str | bytes) -> str | bytes:
|
||||
return key
|
||||
|
||||
|
|
@ -189,8 +213,8 @@ class LocalWeakReferencedCache(weakref.WeakKeyDictionary[_KT, _VT | None]):
|
|||
class SequenceExclude:
|
||||
"""Object to test if an item is NOT within some sequence."""
|
||||
|
||||
def __init__(self, seq: Sequence[Any]):
|
||||
self.seq: Sequence[Any] = seq
|
||||
def __init__(self, seq: Container[Any]):
|
||||
self.seq: Container[Any] = seq
|
||||
|
||||
def __contains__(self, item: Any) -> bool:
|
||||
return item not in self.seq
|
||||
|
|
|
|||
|
|
@ -19,9 +19,19 @@ _T = TypeVar("_T")
|
|||
_P = ParamSpec("_P")
|
||||
|
||||
|
||||
@overload
|
||||
def deprecated(use_instead: Callable[_P, _T]) -> Callable[_P, _T]: ...
|
||||
|
||||
|
||||
@overload
|
||||
def deprecated(
|
||||
use_instead: Any = None,
|
||||
) -> Callable[[Callable[_P, _T]], Callable[_P, _T]]:
|
||||
use_instead: str | None = None,
|
||||
) -> Callable[[Callable[_P, _T]], Callable[_P, _T]]: ...
|
||||
|
||||
|
||||
def deprecated(
|
||||
use_instead: Callable[_P, _T] | str | None = None,
|
||||
) -> Callable[_P, _T] | Callable[[Callable[_P, _T]], Callable[_P, _T]]:
|
||||
"""This is a decorator which can be used to mark functions
|
||||
as deprecated. It will result in a warning being emitted
|
||||
when the function is used."""
|
||||
|
|
@ -38,8 +48,9 @@ def deprecated(
|
|||
return wrapped
|
||||
|
||||
if callable(use_instead):
|
||||
deco = deco(use_instead)
|
||||
func = use_instead
|
||||
use_instead = None
|
||||
return deco(func)
|
||||
return deco
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,35 @@ class MockServerSpider(Spider):
|
|||
self.is_secure = is_secure
|
||||
|
||||
|
||||
class RawResponseSpider(MockServerSpider):
|
||||
"""Base class for spiders that fetch a response built by the test itself.
|
||||
|
||||
Subclasses return the body from :meth:`raw_body` and request
|
||||
:attr:`raw_url`, which the mock server answers with that body verbatim
|
||||
under :attr:`content_type`. This lets tests reach parsing code that only
|
||||
a specific kind of response triggers while still going through a regular
|
||||
crawl, instead of calling internal parsing methods directly.
|
||||
"""
|
||||
|
||||
name = "raw_response"
|
||||
content_type = "text/plain"
|
||||
|
||||
def raw_body(self) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def raw_url(self) -> str:
|
||||
assert self.mockserver
|
||||
raw = (
|
||||
"HTTP/1.1 200 OK\r\n"
|
||||
f"Content-Type: {self.content_type}\r\n"
|
||||
"Connection: close\r\n"
|
||||
"\r\n"
|
||||
f"{self.raw_body()}"
|
||||
)
|
||||
return self.mockserver.url("/raw?" + urlencode({"raw": raw}))
|
||||
|
||||
|
||||
class MetaSpider(MockServerSpider):
|
||||
name = "meta"
|
||||
|
||||
|
|
@ -496,6 +525,23 @@ class CrawlSpiderWithErrback(CrawlSpiderWithParseMethod):
|
|||
self.logger.info("[errback] status %i", failure.value.response.status)
|
||||
|
||||
|
||||
class CrawlSpiderWithoutErrback(CrawlSpiderWithParseMethod):
|
||||
name = "crawl_spider_without_errback"
|
||||
|
||||
async def start(self):
|
||||
test_body = b"""
|
||||
<html>
|
||||
<head><title>Page title</title></head>
|
||||
<body>
|
||||
<p><a href="/status?n=200">Item 200</a></p> <!-- callback -->
|
||||
<p><a href="/status?n=404">Item 404</a></p> <!-- failure, no errback -->
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
url = self.mockserver.url("/alpayload")
|
||||
yield Request(url, method="POST", body=test_body)
|
||||
|
||||
|
||||
class CrawlSpiderWithProcessRequestCallbackKeywordArguments(CrawlSpiderWithParseMethod):
|
||||
name = "crawl_spider_with_process_request_cb_kwargs"
|
||||
rules = (
|
||||
|
|
|
|||
|
|
@ -126,6 +126,32 @@ class MySpider(scrapy.Spider):
|
|||
else:
|
||||
self.logger.debug('It Does Not Work :(')
|
||||
|
||||
class RetryRequestSpider(BaseSpider):
|
||||
name = 'retry_request'
|
||||
|
||||
def parse(self, response):
|
||||
if response.meta.get('retried'):
|
||||
yield {{'retried': True}}
|
||||
return
|
||||
response.meta['retried'] = True
|
||||
yield response.request.replace(dont_filter=True)
|
||||
|
||||
class CustomCallbackRetryRequestSpider(BaseSpider):
|
||||
name = 'retry_request_custom_callback'
|
||||
|
||||
def parse(self, response):
|
||||
yield response.request.replace(
|
||||
callback=self.parse_retry,
|
||||
dont_filter=True,
|
||||
)
|
||||
|
||||
def parse_retry(self, response):
|
||||
if response.meta.get('retried'):
|
||||
yield {{'retried_with_custom_callback': True}}
|
||||
return
|
||||
response.meta['retried'] = True
|
||||
yield response.request.replace(dont_filter=True)
|
||||
|
||||
class MyGoodCrawlSpider(CrawlSpider):
|
||||
name = 'goodcrawl{self.spider_name}'
|
||||
|
||||
|
|
@ -381,6 +407,36 @@ ITEM_PIPELINES = {{'{self.project_name}.pipelines.MyPipeline': 1}}
|
|||
)
|
||||
assert "[{}, {'foo': 'bar'}]" in out
|
||||
|
||||
def test_retry_response_request(
|
||||
self, proj_path: Path, mockserver: MockServer
|
||||
) -> None:
|
||||
_, out, stderr = proc(
|
||||
"parse",
|
||||
"--spider",
|
||||
"retry_request",
|
||||
"-d",
|
||||
"2",
|
||||
mockserver.url("/html"),
|
||||
cwd=proj_path,
|
||||
)
|
||||
assert "RecursionError" not in stderr
|
||||
assert "{'retried': True}" in out
|
||||
|
||||
def test_retry_response_request_with_custom_callback(
|
||||
self, proj_path: Path, mockserver: MockServer
|
||||
) -> None:
|
||||
_, out, stderr = proc(
|
||||
"parse",
|
||||
"--spider",
|
||||
"retry_request_custom_callback",
|
||||
"-d",
|
||||
"3",
|
||||
mockserver.url("/html"),
|
||||
cwd=proj_path,
|
||||
)
|
||||
assert "RecursionError" not in stderr
|
||||
assert "{'retried_with_custom_callback': True}" in out
|
||||
|
||||
def test_wrong_callback_passed(
|
||||
self, proj_path: Path, mockserver: MockServer
|
||||
) -> None:
|
||||
|
|
|
|||
|
|
@ -3,14 +3,12 @@ from __future__ import annotations
|
|||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from io import StringIO
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
import scrapy
|
||||
from scrapy.cmdline import _pop_command_name, _print_unknown_command_msg
|
||||
from scrapy.cmdline import _pop_command_name, execute
|
||||
from scrapy.commands import ScrapyCommand, ScrapyHelpFormatter, view
|
||||
from scrapy.exceptions import ScrapyDeprecationWarning
|
||||
from scrapy.settings import Settings
|
||||
|
|
@ -153,12 +151,6 @@ class MySpider(scrapy.Spider):
|
|||
|
||||
self._append_settings(proj_mod_path, "LOG_LEVEL = 'DEBUG'\n")
|
||||
|
||||
@staticmethod
|
||||
def _append_settings(proj_mod_path: Path, text: str) -> None:
|
||||
"""Add text to the end of the project settings.py."""
|
||||
with (proj_mod_path / "settings.py").open("a", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
|
||||
@staticmethod
|
||||
def _replace_custom_settings(
|
||||
proj_mod_path: Path, spider_name: str, text: str
|
||||
|
|
@ -347,23 +339,223 @@ class TestMiscCommands(TestProjectBase):
|
|||
subdir.mkdir(exist_ok=True)
|
||||
assert call("list", cwd=subdir) == 0
|
||||
|
||||
def test_command_not_found(self) -> None:
|
||||
na_msg = """
|
||||
The list command is not available from this location.
|
||||
These commands are only available from within a project: check, crawl, edit, list, parse.
|
||||
"""
|
||||
not_found_msg = """
|
||||
Unknown command: abc
|
||||
"""
|
||||
params = [
|
||||
("list", False, na_msg),
|
||||
("abc", False, not_found_msg),
|
||||
("abc", True, not_found_msg),
|
||||
]
|
||||
for cmdname, inproject, message in params:
|
||||
with mock.patch("sys.stdout", new=StringIO()) as out:
|
||||
_print_unknown_command_msg(Settings(), cmdname, inproject)
|
||||
assert out.getvalue().strip() == message.strip()
|
||||
|
||||
class TestCommandListing(TestProjectBase):
|
||||
"""Tests for the command list that ``scrapy`` prints when called without a
|
||||
command name."""
|
||||
|
||||
def test_outside_project(self) -> None:
|
||||
returncode, out, err = proc()
|
||||
assert returncode == 0, err
|
||||
assert f"Scrapy {scrapy.__version__} - no active project" in out
|
||||
assert "Available commands:" in out
|
||||
assert "Create new project" in out
|
||||
assert "More commands available when run from project directory" in out
|
||||
assert 'Use "scrapy <command> -h" to see more info about a command' in out
|
||||
|
||||
def test_inside_project(self, proj_path: Path) -> None:
|
||||
returncode, out, err = proc(cwd=proj_path)
|
||||
assert returncode == 0, err
|
||||
assert (
|
||||
f"Scrapy {scrapy.__version__} - active project: {self.project_name}" in out
|
||||
)
|
||||
assert "List available spiders" in out
|
||||
assert "More commands available when run from project directory" not in out
|
||||
|
||||
|
||||
class TestUnknownCommand(TestProjectBase):
|
||||
def test_outside_project(self) -> None:
|
||||
returncode, out, err = proc("abc")
|
||||
assert returncode == 2, err
|
||||
assert f"Scrapy {scrapy.__version__} - no active project" in out
|
||||
assert "Unknown command: abc" in out
|
||||
assert 'Use "scrapy" to see available commands' in out
|
||||
|
||||
def test_inside_project(self, proj_path: Path) -> None:
|
||||
returncode, out, err = proc("abc", cwd=proj_path)
|
||||
assert returncode == 2, err
|
||||
assert (
|
||||
f"Scrapy {scrapy.__version__} - active project: {self.project_name}" in out
|
||||
)
|
||||
assert "Unknown command: abc" in out
|
||||
|
||||
def test_project_only_command_outside_project(self) -> None:
|
||||
returncode, out, err = proc("list")
|
||||
assert returncode == 2, err
|
||||
assert "The list command is not available from this location." in out
|
||||
assert (
|
||||
"These commands are only available from within a project: "
|
||||
"check, crawl, edit, list, parse." in out
|
||||
)
|
||||
|
||||
|
||||
class TestCommandsModule(TestProjectBase):
|
||||
"""Tests for commands defined in the module of the COMMANDS_MODULE setting."""
|
||||
|
||||
@pytest.fixture
|
||||
def proj_path_with_commands(self, proj_path: Path) -> Path:
|
||||
commands_path = proj_path / self.project_name / "commands"
|
||||
commands_path.mkdir()
|
||||
(commands_path / "__init__.py").touch()
|
||||
(commands_path / "mycmd.py").write_text(
|
||||
"""
|
||||
from scrapy.commands import ScrapyCommand
|
||||
|
||||
|
||||
class Command(ScrapyCommand):
|
||||
requires_crawler_process = False
|
||||
|
||||
def short_desc(self):
|
||||
return "My custom command"
|
||||
|
||||
def run(self, args, opts):
|
||||
print("My custom command ran")
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(commands_path / "helpcmd.py").write_text(
|
||||
"""
|
||||
from scrapy.commands import ScrapyCommand
|
||||
from scrapy.exceptions import UsageError
|
||||
|
||||
|
||||
class Command(ScrapyCommand):
|
||||
requires_crawler_process = False
|
||||
|
||||
def short_desc(self):
|
||||
return "My command that asks for its help message"
|
||||
|
||||
def run(self, args, opts):
|
||||
raise UsageError
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(commands_path / "silentcmd.py").write_text(
|
||||
"""
|
||||
from scrapy.commands import ScrapyCommand
|
||||
from scrapy.exceptions import UsageError
|
||||
|
||||
|
||||
class Command(ScrapyCommand):
|
||||
requires_crawler_process = False
|
||||
|
||||
def short_desc(self):
|
||||
return "My command that fails silently"
|
||||
|
||||
def run(self, args, opts):
|
||||
raise UsageError(print_help=False)
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
self._append_settings(
|
||||
proj_path / self.project_name,
|
||||
f'\nCOMMANDS_MODULE = "{self.project_name}.commands"\n',
|
||||
)
|
||||
return proj_path
|
||||
|
||||
def test_listed(self, proj_path_with_commands: Path) -> None:
|
||||
returncode, out, err = proc(cwd=proj_path_with_commands)
|
||||
assert returncode == 0, err
|
||||
assert "My custom command" in out
|
||||
|
||||
def test_run(self, proj_path_with_commands: Path) -> None:
|
||||
returncode, out, err = proc("mycmd", cwd=proj_path_with_commands)
|
||||
assert returncode == 0, err
|
||||
assert "My custom command ran" in out
|
||||
|
||||
def test_usage_error(self, proj_path_with_commands: Path) -> None:
|
||||
"""A message-less UsageError makes the help message be printed."""
|
||||
returncode, out, err = proc("helpcmd", cwd=proj_path_with_commands)
|
||||
assert returncode == 2, err
|
||||
assert "scrapy helpcmd" in out
|
||||
|
||||
def test_usage_error_without_help(self, proj_path_with_commands: Path) -> None:
|
||||
"""A message-less UsageError with print_help disabled prints nothing."""
|
||||
returncode, out, err = proc("silentcmd", cwd=proj_path_with_commands)
|
||||
assert returncode == 2, err
|
||||
assert not out
|
||||
|
||||
|
||||
class TestEntryPointCommands:
|
||||
"""Tests for commands defined in the scrapy.commands entry point group."""
|
||||
|
||||
@staticmethod
|
||||
def _write_dist(path: Path, entry_point: str) -> None:
|
||||
"""Write into *path* a package with a command and a function, and the
|
||||
metadata of an installed distribution that declares *entry_point* in
|
||||
the scrapy.commands entry point group.
|
||||
|
||||
Since ``python -m scrapy.cmdline`` puts the current working directory
|
||||
in the import path, running it with *path* as the working directory
|
||||
makes Scrapy find that entry point.
|
||||
"""
|
||||
package_path = path / "mycmds"
|
||||
package_path.mkdir()
|
||||
(package_path / "__init__.py").touch()
|
||||
(package_path / "mycmd.py").write_text(
|
||||
"""
|
||||
from scrapy.commands import ScrapyCommand
|
||||
|
||||
|
||||
class Command(ScrapyCommand):
|
||||
requires_crawler_process = False
|
||||
|
||||
def short_desc(self):
|
||||
return "My entry point command"
|
||||
|
||||
def run(self, args, opts):
|
||||
print("My entry point command ran")
|
||||
|
||||
|
||||
def not_a_command():
|
||||
pass
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
dist_info_path = path / "mycmds-1.0.dist-info"
|
||||
dist_info_path.mkdir()
|
||||
(dist_info_path / "METADATA").write_text(
|
||||
"Metadata-Version: 2.1\nName: mycmds\nVersion: 1.0\n", encoding="utf-8"
|
||||
)
|
||||
(dist_info_path / "entry_points.txt").write_text(
|
||||
f"[scrapy.commands]\n{entry_point}\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
def test_listed(self, tmp_path: Path) -> None:
|
||||
self._write_dist(tmp_path, "mycmd = mycmds.mycmd:Command")
|
||||
returncode, out, err = proc(cwd=tmp_path)
|
||||
assert returncode == 0, err
|
||||
assert "My entry point command" in out
|
||||
|
||||
def test_run(self, tmp_path: Path) -> None:
|
||||
self._write_dist(tmp_path, "mycmd = mycmds.mycmd:Command")
|
||||
returncode, out, err = proc("mycmd", cwd=tmp_path)
|
||||
assert returncode == 0, err
|
||||
assert "My entry point command ran" in out
|
||||
|
||||
def test_not_a_class(self, tmp_path: Path) -> None:
|
||||
self._write_dist(tmp_path, "mycmd = mycmds.mycmd:not_a_command")
|
||||
returncode, _, err = proc("version", cwd=tmp_path)
|
||||
assert returncode == 1
|
||||
assert "ValueError: Invalid entry point mycmd" in err
|
||||
|
||||
|
||||
class TestExecute:
|
||||
"""Tests for calls to scrapy.cmdline.execute() from Python code, which the
|
||||
command line does not cover."""
|
||||
|
||||
def test_argv(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
execute(["scrapy", "version"])
|
||||
assert exc_info.value.code == 0
|
||||
assert scrapy.__version__ in capsys.readouterr().out
|
||||
|
||||
def test_settings(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
settings = Settings()
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
execute(["scrapy", "settings", "--get", "BOT_NAME"], settings=settings)
|
||||
assert exc_info.value.code == 0
|
||||
assert capsys.readouterr().out.strip() == "scrapybot"
|
||||
|
||||
|
||||
class TestBenchCommand:
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ from tests.spiders import (
|
|||
CrawlSpiderWithAsyncCallback,
|
||||
CrawlSpiderWithAsyncGeneratorCallback,
|
||||
CrawlSpiderWithErrback,
|
||||
CrawlSpiderWithoutErrback,
|
||||
CrawlSpiderWithParseMethod,
|
||||
CrawlSpiderWithProcessRequestCallbackKeywordArguments,
|
||||
DelaySpider,
|
||||
|
|
@ -505,6 +506,21 @@ class TestCrawlSpider:
|
|||
assert "[errback] status 500" in caplog.text
|
||||
assert "[errback] status 501" in caplog.text
|
||||
|
||||
@coroutine_test
|
||||
async def test_crawlspider_without_errback(
|
||||
self, caplog: pytest.LogCaptureFixture, mockserver: MockServer
|
||||
) -> None:
|
||||
crawler = get_crawler(CrawlSpiderWithoutErrback)
|
||||
with caplog.at_level(logging.INFO):
|
||||
await crawler.crawl_async(mockserver=mockserver)
|
||||
|
||||
# The failing request (404) is followed by a rule without an errback,
|
||||
# so the failure is dropped silently and the crawl finishes normally.
|
||||
assert "[parse] status 200 (foo: None)" in caplog.text
|
||||
assert "[errback]" not in caplog.text
|
||||
assert crawler.stats
|
||||
assert crawler.stats.get_value("downloader/response_status_count/404") == 1
|
||||
|
||||
@coroutine_test
|
||||
async def test_crawlspider_process_request_cb_kwargs(
|
||||
self, caplog: pytest.LogCaptureFixture, mockserver: MockServer
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import email.utils
|
||||
import logging
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from unittest import mock
|
||||
|
||||
|
|
@ -93,6 +95,12 @@ class TestBase:
|
|||
class StorageTestMixin:
|
||||
"""Mixin containing storage-specific test methods."""
|
||||
|
||||
def _corrupt_cache_entry(
|
||||
self, storage: Any, spider: Spider, request: Request
|
||||
) -> None:
|
||||
"""Make the cache entry of *request* unreadable for *storage*."""
|
||||
raise NotImplementedError
|
||||
|
||||
def test_storage(self):
|
||||
with self._storage(HTTPCACHE_EXPIRATION_SECS=1) as (storage, crawler):
|
||||
request2 = self.request.copy()
|
||||
|
|
@ -115,6 +123,38 @@ class StorageTestMixin:
|
|||
with mock.patch("scrapy.extensions.httpcache.time", return_value=future):
|
||||
assert storage.retrieve_response(crawler.spider, self.request)
|
||||
|
||||
def test_corrupted_cache_entry_is_a_miss(self, caplog):
|
||||
with self._middleware() as mw:
|
||||
spider = mw.crawler.spider
|
||||
mw.storage.store_response(spider, self.request, self.response)
|
||||
self._corrupt_cache_entry(mw.storage, spider, self.request)
|
||||
|
||||
caplog.clear()
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert mw.process_request(self.request) is None
|
||||
|
||||
assert "treating it as a cache miss" in caplog.text
|
||||
assert mw.crawler.stats.get_value("httpcache/retrieve_error") == 1
|
||||
assert mw.crawler.stats.get_value("httpcache/miss") == 1
|
||||
|
||||
# Storing the response again replaces the corrupted cache entry.
|
||||
mw.storage.store_response(spider, self.request, self.response)
|
||||
self.assertEqualResponse(
|
||||
self.response, mw.storage.retrieve_response(spider, self.request)
|
||||
)
|
||||
|
||||
def test_corrupted_cache_entry_ignore_missing(self):
|
||||
with self._middleware(HTTPCACHE_IGNORE_MISSING=True) as mw:
|
||||
spider = mw.crawler.spider
|
||||
mw.storage.store_response(spider, self.request, self.response)
|
||||
self._corrupt_cache_entry(mw.storage, spider, self.request)
|
||||
|
||||
with pytest.raises(IgnoreRequest):
|
||||
mw.process_request(self.request)
|
||||
|
||||
assert mw.crawler.stats.get_value("httpcache/retrieve_error") == 1
|
||||
assert mw.crawler.stats.get_value("httpcache/ignore") == 1
|
||||
|
||||
def test_storage_no_content_type_header(self):
|
||||
"""Test that the response body is used to get the right response class
|
||||
even if there is no Content-Type header"""
|
||||
|
|
@ -556,29 +596,43 @@ class RFC2616PolicyTestMixin(PolicyTestMixin):
|
|||
# Concrete test classes that combine storage and policy mixins
|
||||
|
||||
|
||||
class TestFilesystemStorageWithDummyPolicy(
|
||||
TestBase, StorageTestMixin, DummyPolicyTestMixin
|
||||
):
|
||||
class FilesystemStorageTestMixin(StorageTestMixin):
|
||||
storage_class = "scrapy.extensions.httpcache.FilesystemCacheStorage"
|
||||
|
||||
def _corrupt_cache_entry(self, storage, spider, request) -> None:
|
||||
rpath = Path(storage._get_request_path(spider, request))
|
||||
(rpath / "response_body").unlink()
|
||||
|
||||
|
||||
class DbmStorageTestMixin(StorageTestMixin):
|
||||
storage_class = "scrapy.extensions.httpcache.DbmCacheStorage"
|
||||
|
||||
def _corrupt_cache_entry(self, storage, spider, request) -> None:
|
||||
key = storage._fingerprinter.fingerprint(request).hex()
|
||||
storage.db[f"{key}_data"] = b"not a pickle"
|
||||
|
||||
|
||||
class TestFilesystemStorageWithDummyPolicy(
|
||||
TestBase, FilesystemStorageTestMixin, DummyPolicyTestMixin
|
||||
):
|
||||
policy_class = "scrapy.extensions.httpcache.DummyPolicy"
|
||||
|
||||
|
||||
class TestFilesystemStorageWithRFC2616Policy(
|
||||
TestBase, StorageTestMixin, RFC2616PolicyTestMixin
|
||||
TestBase, FilesystemStorageTestMixin, RFC2616PolicyTestMixin
|
||||
):
|
||||
storage_class = "scrapy.extensions.httpcache.FilesystemCacheStorage"
|
||||
policy_class = "scrapy.extensions.httpcache.RFC2616Policy"
|
||||
|
||||
|
||||
class TestDbmStorageWithDummyPolicy(TestBase, StorageTestMixin, DummyPolicyTestMixin):
|
||||
storage_class = "scrapy.extensions.httpcache.DbmCacheStorage"
|
||||
class TestDbmStorageWithDummyPolicy(
|
||||
TestBase, DbmStorageTestMixin, DummyPolicyTestMixin
|
||||
):
|
||||
policy_class = "scrapy.extensions.httpcache.DummyPolicy"
|
||||
|
||||
|
||||
class TestDbmStorageWithRFC2616Policy(
|
||||
TestBase, StorageTestMixin, RFC2616PolicyTestMixin
|
||||
TestBase, DbmStorageTestMixin, RFC2616PolicyTestMixin
|
||||
):
|
||||
storage_class = "scrapy.extensions.httpcache.DbmCacheStorage"
|
||||
policy_class = "scrapy.extensions.httpcache.RFC2616Policy"
|
||||
|
||||
|
||||
|
|
@ -599,3 +653,8 @@ class TestFilesystemStorageGzipWithDummyPolicy(TestFilesystemStorageWithDummyPol
|
|||
def _get_settings(self, **new_settings) -> dict[str, Any]:
|
||||
new_settings.setdefault("HTTPCACHE_GZIP", True)
|
||||
return super()._get_settings(**new_settings)
|
||||
|
||||
def _corrupt_cache_entry(self, storage, spider, request) -> None:
|
||||
# A spider killed while writing a gzip file leaves it truncated.
|
||||
body_path = Path(storage._get_request_path(spider, request), "response_body")
|
||||
body_path.write_bytes(body_path.read_bytes()[:-5])
|
||||
|
|
|
|||
|
|
@ -211,6 +211,47 @@ class TestBatchDeliveries(TestFeedExportBase):
|
|||
header = MyItem.fields.keys()
|
||||
await self.assertExported(items, header, rows, settings=settings)
|
||||
|
||||
@coroutine_test
|
||||
async def test_batch_delivered_when_full(self):
|
||||
"""Full batches must be finalized and delivered as soon as they are
|
||||
full, instead of when the spider closes."""
|
||||
dir_path = self._random_temp_filename()
|
||||
batch1_path = Path(dir_path, "1.json")
|
||||
mockserver_url = self.mockserver.url("/")
|
||||
batch1_contents: list[bytes | None] = []
|
||||
|
||||
class TestSpider(scrapy.Spider):
|
||||
name = "testspider"
|
||||
start_urls = [mockserver_url]
|
||||
|
||||
def parse(self, response):
|
||||
yield {"foo": "bar1"}
|
||||
yield {"foo": "bar2"}
|
||||
yield scrapy.Request(
|
||||
mockserver_url, callback=self.parse2, dont_filter=True
|
||||
)
|
||||
|
||||
def parse2(self, response):
|
||||
# the first batch was full after the second item, so it must
|
||||
# have been delivered by now
|
||||
batch1_contents.append(
|
||||
batch1_path.read_bytes() if batch1_path.exists() else None
|
||||
)
|
||||
yield {"foo": "bar3"}
|
||||
|
||||
settings = {
|
||||
"FEEDS": {
|
||||
build_url(dir_path / "%(batch_id)d.json"): {"format": "json"},
|
||||
},
|
||||
"FEED_EXPORT_BATCH_ITEM_COUNT": 2,
|
||||
}
|
||||
crawler = get_crawler(TestSpider, settings)
|
||||
await crawler.crawl_async()
|
||||
|
||||
assert batch1_contents, "the second request was not processed"
|
||||
assert batch1_contents[0] is not None, "batch 1 was not stored during the crawl"
|
||||
assert json.loads(batch1_contents[0]) == [{"foo": "bar1"}, {"foo": "bar2"}]
|
||||
|
||||
def test_wrong_path(self):
|
||||
"""If path is without %(batch_time)s and %(batch_id) an exception must be raised"""
|
||||
settings = {
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ from scrapy.pipelines.files import (
|
|||
GCSFilesStore,
|
||||
S3FilesStore,
|
||||
)
|
||||
from scrapy.pipelines.media import MediaPipeline, _MediaRequestFiltered
|
||||
from scrapy.pipelines.media import _MediaRequestFiltered
|
||||
from scrapy.settings import Settings
|
||||
from scrapy.utils.asyncio import call_later
|
||||
from scrapy.utils.defer import maybe_deferred_to_future
|
||||
|
|
@ -43,11 +43,7 @@ from tests.mockserver.ftp import MockFTPServer
|
|||
from tests.utils.decorators import coroutine_test, inline_callbacks_test
|
||||
|
||||
from .utils.cloud import mock_google_cloud_storage
|
||||
from .utils.media_pipelines import mocked_download_func
|
||||
|
||||
# required by persist_file() and stat_file(), but as some stores don't use the argument
|
||||
# we can pass this singleton to keep type hints correct
|
||||
DUMMY_SPIDER_INFO = MediaPipeline.SpiderInfo(DefaultSpider())
|
||||
from .utils.media_pipelines import DUMMY_SPIDER_INFO, mocked_download_func
|
||||
|
||||
|
||||
def get_ftp_content_and_delete(
|
||||
|
|
@ -94,16 +90,19 @@ class DeferredFSFilesStore(FSFilesStore):
|
|||
class TestFilesPipeline:
|
||||
def setup_method(self):
|
||||
self.tempdir = mkdtemp()
|
||||
settings_dict = {"FILES_STORE": self.tempdir}
|
||||
crawler = get_crawler(DefaultSpider, settings_dict=settings_dict)
|
||||
crawler.spider = crawler._create_spider()
|
||||
crawler.engine = MagicMock(download_async=mocked_download_func)
|
||||
self.pipeline = FilesPipeline.from_crawler(crawler)
|
||||
self.pipeline.open_spider()
|
||||
self.pipeline = self._create_pipeline(FilesPipeline)
|
||||
|
||||
def teardown_method(self):
|
||||
rmtree(self.tempdir)
|
||||
|
||||
def _create_pipeline(self, pipeline_cls: type[FilesPipeline]) -> FilesPipeline:
|
||||
crawler = get_crawler(DefaultSpider, {"FILES_STORE": self.tempdir})
|
||||
crawler.spider = crawler._create_spider()
|
||||
crawler.engine = MagicMock(download_async=mocked_download_func)
|
||||
pipeline = pipeline_cls.from_crawler(crawler)
|
||||
pipeline.open_spider()
|
||||
return pipeline
|
||||
|
||||
@coroutine_test
|
||||
async def test_close_spider_closes_store(self):
|
||||
"""close_spider() closes the store when it exposes a close() method."""
|
||||
|
|
@ -268,6 +267,107 @@ class TestFilesPipeline:
|
|||
assert result["files"][0]["checksum"] != "abc"
|
||||
assert result["files"][0]["status"] == "cached"
|
||||
|
||||
@coroutine_test
|
||||
async def test_file_stat_without_last_modified(self) -> None:
|
||||
"""A stat result without a last modification time forces a download."""
|
||||
item_url = "http://example.com/file4.pdf"
|
||||
item = _create_item_with_files(item_url)
|
||||
with (
|
||||
mock.patch.object(FilesPipeline, "inc_stats", return_value=True),
|
||||
mock.patch.object(
|
||||
FSFilesStore, "stat_file", return_value={"checksum": "abc"}
|
||||
),
|
||||
mock.patch.object(
|
||||
FilesPipeline,
|
||||
"get_media_requests",
|
||||
return_value=[_prepare_request_object(item_url)],
|
||||
),
|
||||
):
|
||||
result = await self.pipeline.process_item(item)
|
||||
assert result["files"][0]["checksum"] != "abc"
|
||||
assert result["files"][0]["status"] == "downloaded"
|
||||
|
||||
@coroutine_test
|
||||
async def test_file_empty_content(self, caplog: pytest.LogCaptureFixture) -> None:
|
||||
item_url = "http://example.com/empty.pdf"
|
||||
item = _create_item_with_files(item_url)
|
||||
request = Request(
|
||||
item_url, meta={"response": Response(item_url, status=200, body=b"")}
|
||||
)
|
||||
with (
|
||||
caplog.at_level(logging.WARNING),
|
||||
mock.patch.object(
|
||||
FilesPipeline, "get_media_requests", return_value=[request]
|
||||
),
|
||||
):
|
||||
result = await self.pipeline.process_item(item)
|
||||
assert result["files"] == []
|
||||
assert "File (empty-content): Empty file from" in caplog.text
|
||||
|
||||
@coroutine_test
|
||||
async def test_file_downloaded_file_exception(
|
||||
self, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A FileException from file_downloaded() is logged as a warning and
|
||||
kept as is."""
|
||||
|
||||
class FailingFilesPipeline(FilesPipeline):
|
||||
def file_downloaded(self, response, request, info, *, item=None):
|
||||
raise FileException("boom")
|
||||
|
||||
item_url = "http://example.com/file5.pdf"
|
||||
item = _create_item_with_files(item_url)
|
||||
pipeline = self._create_pipeline(FailingFilesPipeline)
|
||||
with (
|
||||
caplog.at_level(logging.WARNING),
|
||||
mock.patch.object(
|
||||
FilesPipeline,
|
||||
"get_media_requests",
|
||||
return_value=[_prepare_request_object(item_url)],
|
||||
),
|
||||
):
|
||||
result = await pipeline.process_item(item)
|
||||
assert result["files"] == []
|
||||
records = [
|
||||
r for r in caplog.records if "Error processing file" in r.getMessage()
|
||||
]
|
||||
assert len(records) == 1
|
||||
assert records[0].levelname == "WARNING"
|
||||
assert "boom" in records[0].getMessage()
|
||||
|
||||
@coroutine_test
|
||||
async def test_file_downloaded_unknown_error(
|
||||
self, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Any other exception from file_downloaded() is logged as an error and
|
||||
reported as a FileException."""
|
||||
|
||||
class FailingFilesPipeline(FilesPipeline):
|
||||
def file_downloaded(self, response, request, info, *, item=None):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
item_url = "http://example.com/file6.pdf"
|
||||
item = _create_item_with_files(item_url)
|
||||
pipeline = self._create_pipeline(FailingFilesPipeline)
|
||||
with (
|
||||
caplog.at_level(logging.WARNING),
|
||||
mock.patch.object(
|
||||
FilesPipeline,
|
||||
"get_media_requests",
|
||||
return_value=[_prepare_request_object(item_url)],
|
||||
),
|
||||
):
|
||||
result = await pipeline.process_item(item)
|
||||
assert result["files"] == []
|
||||
records = [
|
||||
r for r in caplog.records if "Error processing file" in r.getMessage()
|
||||
]
|
||||
assert len(records) == 1
|
||||
assert records[0].levelname == "ERROR"
|
||||
exc_info = records[0].exc_info
|
||||
assert exc_info is not None
|
||||
assert exc_info[0] is RuntimeError
|
||||
|
||||
@coroutine_test
|
||||
async def test_async_store(self) -> None:
|
||||
"""Test that async persist_file() works and is awaited."""
|
||||
|
|
@ -662,9 +762,24 @@ class TestFilesPipelineCustomSettings:
|
|||
request = Request("http://example.com/image01.jpg")
|
||||
assert pipeline.file_path(request) == Path("subdir/image01.jpg")
|
||||
|
||||
def test_files_store_constructor_with_pathlike_object(self, tmp_path):
|
||||
fs_store = FSFilesStore(tmp_path)
|
||||
assert fs_store.basedir == str(tmp_path)
|
||||
|
||||
class TestFSFilesStore:
|
||||
def test_constructor_with_pathlike_object(self, tmp_path: Path) -> None:
|
||||
assert FSFilesStore(tmp_path).basedir == str(tmp_path)
|
||||
|
||||
def test_constructor_with_uri(self, tmp_path: Path) -> None:
|
||||
assert FSFilesStore(f"file://{tmp_path}").basedir == str(tmp_path)
|
||||
|
||||
def test_stat_file(self, tmp_path: Path) -> None:
|
||||
store = FSFilesStore(tmp_path)
|
||||
store.persist_file("full/filename", BytesIO(b"data"), DUMMY_SPIDER_INFO)
|
||||
stat = store.stat_file("full/filename", DUMMY_SPIDER_INFO)
|
||||
assert stat["checksum"] == "8d777f385d3dfec8815d20f7496026dc"
|
||||
assert stat["last_modified"] == pytest.approx(time.time(), abs=60)
|
||||
|
||||
def test_stat_missing_file(self, tmp_path: Path) -> None:
|
||||
store = FSFilesStore(tmp_path)
|
||||
assert store.stat_file("full/filename", DUMMY_SPIDER_INFO) == {}
|
||||
|
||||
|
||||
@pytest.mark.requires_botocore
|
||||
|
|
@ -712,6 +827,60 @@ class TestS3FilesStore:
|
|||
# The call to read does not happen with Stubber
|
||||
assert buffer.method_calls == [mock.call.seek(0)]
|
||||
|
||||
@inline_callbacks_test
|
||||
def test_persist_without_headers(self, monkeypatch):
|
||||
"""Without custom headers only the default ones are sent."""
|
||||
bucket = "mybucket"
|
||||
key = "export.csv"
|
||||
buffer = mock.MagicMock()
|
||||
|
||||
store = S3FilesStore(f"s3://{bucket}/{key}")
|
||||
monkeypatch.setattr(store, "_use_async", lambda: False)
|
||||
from botocore.stub import Stubber # noqa: PLC0415
|
||||
|
||||
with Stubber(store.s3_client) as stub:
|
||||
stub.add_response(
|
||||
"put_object",
|
||||
expected_params={
|
||||
"ACL": S3FilesStore.POLICY,
|
||||
"Body": buffer,
|
||||
"Bucket": bucket,
|
||||
"CacheControl": S3FilesStore.HEADERS["Cache-Control"],
|
||||
"Key": key,
|
||||
"Metadata": {},
|
||||
},
|
||||
service_response={},
|
||||
)
|
||||
|
||||
yield store.persist_file("", buffer, info=DUMMY_SPIDER_INFO)
|
||||
|
||||
stub.assert_no_pending_responses()
|
||||
|
||||
def test_missing_botocore(self):
|
||||
with (
|
||||
mock.patch(
|
||||
"scrapy.pipelines.files.is_botocore_available", return_value=False
|
||||
),
|
||||
pytest.raises(NotConfigured, match="missing botocore library"),
|
||||
):
|
||||
S3FilesStore("s3://mybucket/key")
|
||||
|
||||
def test_wrong_uri_scheme(self):
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=re.escape(
|
||||
"Incorrect URI scheme in ftp://mybucket/key, expected 's3'"
|
||||
),
|
||||
):
|
||||
S3FilesStore("ftp://mybucket/key")
|
||||
|
||||
def test_unsupported_header(self):
|
||||
store = S3FilesStore("s3://mybucket/key")
|
||||
with pytest.raises(
|
||||
TypeError, match='Header "X-Custom" is not supported by botocore'
|
||||
):
|
||||
store._headers_to_botocore_kwargs({"X-Custom": "value"})
|
||||
|
||||
@inline_callbacks_test
|
||||
def test_stat(self, monkeypatch):
|
||||
"""The blocking botocore client is used when asyncio/aiobotocore support
|
||||
|
|
@ -1020,6 +1189,28 @@ class TestFTPFileStore:
|
|||
)
|
||||
assert data == content
|
||||
|
||||
@inline_callbacks_test
|
||||
def test_persist_active_mode(self, monkeypatch: pytest.MonkeyPatch):
|
||||
data = b"active mode"
|
||||
path = "full/filename"
|
||||
monkeypatch.setattr(FTPFilesStore, "FTP_USERNAME", "anonymous")
|
||||
monkeypatch.setattr(FTPFilesStore, "FTP_PASSWORD", "guest")
|
||||
monkeypatch.setattr(FTPFilesStore, "USE_ACTIVE_MODE", True)
|
||||
with MockFTPServer() as ftp_server:
|
||||
store = FTPFilesStore(ftp_server.url("/"))
|
||||
yield store.persist_file(path, BytesIO(data), info=DUMMY_SPIDER_INFO)
|
||||
stat = yield store.stat_file(path, info=DUMMY_SPIDER_INFO)
|
||||
assert stat["checksum"] == "ff1575649a39a27c13faa0d37c84bab3"
|
||||
|
||||
def test_wrong_uri_scheme(self):
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=re.escape(
|
||||
"Incorrect URI scheme in http://example.com/, expected 'ftp'"
|
||||
),
|
||||
):
|
||||
FTPFilesStore("http://example.com/")
|
||||
|
||||
|
||||
class ItemWithFiles(Item):
|
||||
file_urls = Field()
|
||||
|
|
|
|||
|
|
@ -3,20 +3,26 @@ from __future__ import annotations
|
|||
import dataclasses
|
||||
import io
|
||||
import random
|
||||
import sys
|
||||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
from shutil import rmtree
|
||||
from tempfile import mkdtemp
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import attr
|
||||
import pytest
|
||||
from itemadapter import ItemAdapter
|
||||
|
||||
from scrapy.exceptions import NotConfigured
|
||||
from scrapy.http import Request, Response
|
||||
from scrapy.item import Field, Item
|
||||
from scrapy.pipelines.files import GCSFilesStore, S3FilesStore
|
||||
from scrapy.pipelines.files import GCSFilesStore, S3FilesStore, _md5sum
|
||||
from scrapy.pipelines.images import ImageException, ImagesPipeline
|
||||
from scrapy.utils.test import get_crawler
|
||||
from tests.utils.decorators import coroutine_test
|
||||
from tests.utils.media_pipelines import DUMMY_SPIDER_INFO
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
|
|
@ -40,6 +46,11 @@ class TestImagesPipeline:
|
|||
def teardown_method(self):
|
||||
rmtree(self.tempdir)
|
||||
|
||||
def test_missing_pillow(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setitem(sys.modules, "PIL", None)
|
||||
with pytest.raises(NotConfigured, match="requires installing Pillow"):
|
||||
ImagesPipeline(self.tempdir, crawler=get_crawler())
|
||||
|
||||
def test_file_path(self):
|
||||
file_path = self.pipeline.file_path
|
||||
assert (
|
||||
|
|
@ -197,6 +208,25 @@ class TestImagesPipeline:
|
|||
assert path == "full/3fd165099d8e71b8a48b2683946e64dbfad8b52d.jpg"
|
||||
assert new_im.getpixel((0, 0)) == (255, 0, 0)
|
||||
|
||||
@coroutine_test
|
||||
async def test_image_downloaded(self) -> None:
|
||||
"""The image and its thumbnails are stored, and the checksum of the
|
||||
full-size image is returned."""
|
||||
self.pipeline.thumbs = {"small": (20, 20)}
|
||||
_, buf = _create_image("JPEG", "RGB", (50, 50), (0, 0, 0))
|
||||
url = "https://dev.mydeco.com/mydeco.gif"
|
||||
response = Response(url=url, body=buf.getvalue())
|
||||
|
||||
checksum = await self.pipeline.image_downloaded(
|
||||
response, Request(url=url), DUMMY_SPIDER_INFO
|
||||
)
|
||||
|
||||
buf.seek(0)
|
||||
assert checksum == _md5sum(buf)
|
||||
name = "3fd165099d8e71b8a48b2683946e64dbfad8b52d.jpg"
|
||||
assert Path(self.tempdir, "full", name).read_bytes() == buf.getvalue()
|
||||
assert Path(self.tempdir, "thumbs", "small", name).exists()
|
||||
|
||||
def test_convert_image(self):
|
||||
SIZE = (100, 100)
|
||||
# straight forward case: RGB and JPEG
|
||||
|
|
@ -230,6 +260,24 @@ class TestImagesPipeline:
|
|||
assert converted.mode == "RGB"
|
||||
assert converted.getcolors() == [(10000, (205, 230, 255))]
|
||||
|
||||
def test_convert_image_legacy_resampling_filter(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Pillow older than 9.1.0 has Image.ANTIALIAS instead of
|
||||
Image.Resampling.LANCZOS."""
|
||||
# Image.LANCZOS is the only spelling that exists in every supported
|
||||
# Pillow version, but Pillow defines it dynamically, hence the ignore.
|
||||
monkeypatch.setattr(
|
||||
self.pipeline,
|
||||
"_Image",
|
||||
SimpleNamespace(ANTIALIAS=Image.LANCZOS), # type: ignore[attr-defined]
|
||||
)
|
||||
im, buf = _create_image("JPEG", "RGB", (100, 100), (0, 127, 255))
|
||||
|
||||
thumbnail, _ = self.pipeline.convert_image(im, size=(10, 25), response_body=buf)
|
||||
|
||||
assert thumbnail.size == (10, 10)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad_type",
|
||||
[
|
||||
|
|
@ -581,7 +629,7 @@ class TestImagesPipelineCustomSettings:
|
|||
GCSFilesStore.POLICY = old_policy
|
||||
|
||||
|
||||
def _create_image(format_, *a, **kw):
|
||||
def _create_image(format_: str, *a: Any, **kw: Any) -> tuple[Image.Image, io.BytesIO]:
|
||||
buf = io.BytesIO()
|
||||
Image.new(*a, **kw).save(buf, format_)
|
||||
buf.seek(0)
|
||||
|
|
|
|||
|
|
@ -319,6 +319,42 @@ class TestMediaPipeline(TestBaseMediaPipeline):
|
|||
assert self.fingerprint(req1) == self.fingerprint(req2)
|
||||
assert new_item["results"] == [(True, {})]
|
||||
|
||||
@coroutine_test
|
||||
async def test_failures_are_cached_across_multiple_items(self):
|
||||
self.pipe.LOG_FAILED_RESULTS = False
|
||||
exc = Exception("foo")
|
||||
req1 = Request("http://url1", meta={"response": exc})
|
||||
new_item = await self.pipe.process_item({"requests": req1})
|
||||
assert new_item["results"][0][1].value is exc
|
||||
|
||||
# rsp2 is ignored, the cached failure must be reused because request
|
||||
# fingerprints are the same
|
||||
req2 = Request(
|
||||
req1.url, meta={"response": Response("http://donot.download.me")}
|
||||
)
|
||||
new_item = await self.pipe.process_item({"requests": req2})
|
||||
assert new_item["results"][0][0] is False
|
||||
assert new_item["results"][0][1].value is exc
|
||||
assert self.pipe._mockcalled.count("media_to_download") == 1
|
||||
|
||||
@coroutine_test
|
||||
async def test_cached_failure_calls_errback(self):
|
||||
"""The errback of a request is called for a cached failure as well."""
|
||||
self.pipe.LOG_FAILED_RESULTS = False
|
||||
exc = Exception("foo")
|
||||
await self.pipe.process_item(
|
||||
{"requests": Request("http://url1", meta={"response": exc})}
|
||||
)
|
||||
|
||||
def errback(failure):
|
||||
self.pipe._mockcalled.append("request_errback")
|
||||
return {"recovered": failure.value}
|
||||
|
||||
req = Request("http://url1", errback=errback)
|
||||
new_item = await self.pipe.process_item({"requests": req})
|
||||
assert new_item["results"] == [(True, {"recovered": exc})]
|
||||
assert self.pipe._mockcalled.count("request_errback") == 1
|
||||
|
||||
@coroutine_test
|
||||
async def test_results_are_cached_for_requests_of_single_item(self):
|
||||
rsp1 = Response("http://url1")
|
||||
|
|
@ -472,6 +508,30 @@ class TestBuildFromCrawler:
|
|||
assert pipe._from_crawler_called
|
||||
|
||||
|
||||
class MediaFailedNonePipeline(MockedMediaPipeline):
|
||||
def media_failed(self, failure, request, info):
|
||||
self._mockcalled.append("media_failed")
|
||||
|
||||
|
||||
class TestMediaFailedNone(TestBaseMediaPipeline):
|
||||
"""Test what happens when media_failed() neither raises an exception nor
|
||||
returns a failure."""
|
||||
|
||||
pipeline_class = MediaFailedNonePipeline
|
||||
|
||||
@coroutine_test
|
||||
async def test_result_none(self):
|
||||
req = Request("http://url1", meta={"response": Exception("foo")})
|
||||
new_item = await self.pipe.process_item({"requests": req})
|
||||
assert new_item["results"] == [(True, None)]
|
||||
assert self.pipe._mockcalled == [
|
||||
"get_media_requests",
|
||||
"media_to_download",
|
||||
"media_failed",
|
||||
"item_completed",
|
||||
]
|
||||
|
||||
|
||||
class MediaFailedFailurePipeline(MockedMediaPipeline):
|
||||
def media_failed(self, failure, request, info):
|
||||
self._mockcalled.append("media_failed")
|
||||
|
|
|
|||
|
|
@ -1,11 +1,26 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from scrapy.http import Response, TextResponse, XmlResponse
|
||||
from scrapy.http import Request, Response, TextResponse, XmlResponse
|
||||
from scrapy.spiders import CSVFeedSpider, Spider, XMLFeedSpider
|
||||
from tests import get_testdata
|
||||
from tests.spiders import RawResponseSpider
|
||||
from tests.utils.bases.spider import TestSpiderBase
|
||||
from tests.utils.crawl import crawl_items
|
||||
from tests.utils.decorators import coroutine_test
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tests.mockserver.http import MockServer
|
||||
|
||||
|
||||
class RawFeedSpider(RawResponseSpider):
|
||||
content_type = "text/xml"
|
||||
|
||||
async def start(self):
|
||||
yield Request(self.raw_url)
|
||||
|
||||
|
||||
class TestSpider(TestSpiderBase):
|
||||
|
|
@ -60,6 +75,89 @@ class TestXMLFeedSpider(TestSpiderBase):
|
|||
},
|
||||
], iterator
|
||||
|
||||
@coroutine_test
|
||||
async def test_parse_node_uses_parse_item(self, mockserver: MockServer):
|
||||
# parse_node falls back to parse_item for backward compatibility.
|
||||
class _Spider(RawFeedSpider, self.spider_class): # type: ignore[name-defined,misc]
|
||||
itertag = "item"
|
||||
|
||||
def raw_body(self):
|
||||
return "<items><item><id>1</id></item></items>"
|
||||
|
||||
def parse_item(self, response, selector):
|
||||
return {"id": selector.xpath("id/text()").get()}
|
||||
|
||||
items, _ = await crawl_items(_Spider, mockserver)
|
||||
assert items == [{"id": "1"}]
|
||||
|
||||
@coroutine_test
|
||||
async def test_parse_node_not_defined(self, mockserver: MockServer):
|
||||
class _Spider(RawFeedSpider, self.spider_class): # type: ignore[name-defined,misc]
|
||||
itertag = "item"
|
||||
|
||||
def raw_body(self):
|
||||
return "<items><item><id>1</id></item></items>"
|
||||
|
||||
items, crawler = await crawl_items(_Spider, mockserver)
|
||||
assert items == []
|
||||
assert crawler.stats
|
||||
assert crawler.stats.get_value("spider_exceptions/NotImplementedError") == 1
|
||||
|
||||
@coroutine_test
|
||||
async def test_html_iterator(self, mockserver: MockServer):
|
||||
class _Spider(RawFeedSpider, self.spider_class): # type: ignore[name-defined,misc]
|
||||
iterator = "html"
|
||||
itertag = "item"
|
||||
content_type = "text/html"
|
||||
|
||||
def raw_body(self):
|
||||
return (
|
||||
"<html><body><item><id>1</id></item>"
|
||||
"<item><id>2</id></item></body></html>"
|
||||
)
|
||||
|
||||
def parse_node(self, response, selector):
|
||||
return {"id": selector.xpath("id/text()").get()}
|
||||
|
||||
items, _ = await crawl_items(_Spider, mockserver)
|
||||
assert items == [{"id": "1"}, {"id": "2"}]
|
||||
|
||||
@coroutine_test
|
||||
async def test_unsupported_iterator(self, mockserver: MockServer):
|
||||
class _Spider(RawFeedSpider, self.spider_class): # type: ignore[name-defined,misc]
|
||||
iterator = "unsupported"
|
||||
|
||||
def raw_body(self):
|
||||
return "<items><item/></items>"
|
||||
|
||||
def parse_node(self, response, selector):
|
||||
return {}
|
||||
|
||||
items, crawler = await crawl_items(_Spider, mockserver)
|
||||
assert items == []
|
||||
assert crawler.stats
|
||||
assert crawler.stats.get_value("spider_exceptions/NotSupported") == 1
|
||||
|
||||
@pytest.mark.parametrize("feed_iterator", ["xml", "html"])
|
||||
@coroutine_test
|
||||
async def test_non_text_response(self, feed_iterator: str, mockserver: MockServer):
|
||||
# The xml and html iterators require a text response.
|
||||
class _Spider(RawFeedSpider, self.spider_class): # type: ignore[name-defined,misc]
|
||||
content_type = "application/octet-stream"
|
||||
iterator = feed_iterator
|
||||
|
||||
def raw_body(self):
|
||||
# A binary (non-text) body, so the response is a plain Response.
|
||||
return "\x00\x01\x02\x03"
|
||||
|
||||
def parse_node(self, response, selector):
|
||||
return {}
|
||||
|
||||
items, crawler = await crawl_items(_Spider, mockserver)
|
||||
assert items == []
|
||||
assert crawler.stats
|
||||
assert crawler.stats.get_value("spider_exceptions/ValueError") == 1
|
||||
|
||||
|
||||
class TestCSVFeedSpider(TestSpiderBase):
|
||||
spider_class = CSVFeedSpider
|
||||
|
|
@ -81,6 +179,36 @@ class TestCSVFeedSpider(TestSpiderBase):
|
|||
assert rows[0] == {"id": "1", "name": "alpha", "value": "foobar"}
|
||||
assert len(rows) == 4
|
||||
|
||||
@coroutine_test
|
||||
async def test_parse(self, mockserver: MockServer):
|
||||
class _Spider(RawFeedSpider, self.spider_class): # type: ignore[name-defined,misc]
|
||||
content_type = "text/csv"
|
||||
delimiter = ","
|
||||
quotechar = "'"
|
||||
|
||||
def raw_body(self):
|
||||
return get_testdata("feeds", "feed-sample6.csv").decode()
|
||||
|
||||
def parse_row(self, response, row):
|
||||
return row
|
||||
|
||||
items, _ = await crawl_items(_Spider, mockserver)
|
||||
assert items[0] == {"id": "1", "name": "alpha", "value": "foobar"}
|
||||
assert len(items) == 4
|
||||
|
||||
@coroutine_test
|
||||
async def test_parse_row_not_defined(self, mockserver: MockServer):
|
||||
class _Spider(RawFeedSpider, self.spider_class): # type: ignore[name-defined,misc]
|
||||
content_type = "text/csv"
|
||||
|
||||
def raw_body(self):
|
||||
return "id\n1\n"
|
||||
|
||||
items, crawler = await crawl_items(_Spider, mockserver)
|
||||
assert items == []
|
||||
assert crawler.stats
|
||||
assert crawler.stats.get_value("spider_exceptions/NotImplementedError") == 1
|
||||
|
||||
|
||||
class TestNoParseMethodSpider:
|
||||
spider_class = Spider
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from scrapy.linkextractors import LinkExtractor
|
|||
from scrapy.spiders import CrawlSpider, Rule, Spider
|
||||
from scrapy.utils.test import get_crawler
|
||||
from tests.utils.bases.spider import TestSpiderBase
|
||||
from tests.utils.decorators import coroutine_test
|
||||
|
||||
|
||||
class TestCrawlSpider(TestSpiderBase):
|
||||
|
|
@ -293,6 +294,48 @@ class TestCrawlSpider(TestSpiderBase):
|
|||
TextResponse(spider.start_urls, body=b""), None, None
|
||||
)
|
||||
|
||||
@coroutine_test
|
||||
async def test_parse_with_rules_without_callback(self):
|
||||
response = HtmlResponse(
|
||||
"http://example.org/somepage/index.html", body=self.test_body
|
||||
)
|
||||
|
||||
class _CrawlSpider(CrawlSpider):
|
||||
name = "test"
|
||||
allowed_domains = ["example.org"]
|
||||
rules = (Rule(),)
|
||||
|
||||
spider = _CrawlSpider.from_crawler(get_crawler(_CrawlSpider))
|
||||
results = [
|
||||
r async for r in spider.parse_with_rules(response, None, {}, follow=True)
|
||||
]
|
||||
assert [r.url for r in results] == [
|
||||
"http://example.org/somepage/item/12.html",
|
||||
"http://example.org/about.html",
|
||||
"http://example.org/nofollow.html",
|
||||
]
|
||||
|
||||
@coroutine_test
|
||||
async def test_parse_with_rules_without_following(self):
|
||||
response = HtmlResponse(
|
||||
"http://example.org/somepage/index.html", body=self.test_body
|
||||
)
|
||||
item = {"name": "item"}
|
||||
|
||||
class _CrawlSpider(CrawlSpider):
|
||||
name = "test"
|
||||
allowed_domains = ["example.org"]
|
||||
rules = (Rule(),)
|
||||
|
||||
spider = _CrawlSpider.from_crawler(get_crawler(_CrawlSpider))
|
||||
results = [
|
||||
r
|
||||
async for r in spider.parse_with_rules(
|
||||
response, lambda response: [item], {}, follow=False
|
||||
)
|
||||
]
|
||||
assert results == [item]
|
||||
|
||||
|
||||
class TestDeprecation:
|
||||
def test_crawl_spider(self):
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from datetime import datetime
|
|||
from io import BytesIO
|
||||
from logging import WARNING
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -13,9 +14,30 @@ from scrapy.http import HtmlResponse, Request, Response, TextResponse, XmlRespon
|
|||
from scrapy.spiders import SitemapSpider
|
||||
from scrapy.utils.test import get_crawler
|
||||
from tests import tests_datadir
|
||||
from tests.spiders import RawResponseSpider
|
||||
from tests.utils.bases.spider import TestSpiderBase
|
||||
from tests.utils.crawl import crawl_items
|
||||
from tests.utils.decorators import coroutine_test
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tests.mockserver.http import MockServer
|
||||
|
||||
|
||||
class RawSitemapSpider(RawResponseSpider):
|
||||
"""Feeds :meth:`raw_body` to :class:`~scrapy.spiders.SitemapSpider` as a
|
||||
sitemap, so that it is fetched and followed through a regular crawl.
|
||||
|
||||
Subclasses build the document in :meth:`raw_body`, typically using
|
||||
:attr:`mockserver` to point ``<loc>`` entries at real endpoints.
|
||||
"""
|
||||
|
||||
content_type = "application/xml"
|
||||
|
||||
async def start(self):
|
||||
self.sitemap_urls = [self.raw_url]
|
||||
async for request in super().start():
|
||||
yield request
|
||||
|
||||
|
||||
class TestSitemapSpider(TestSpiderBase):
|
||||
spider_class = SitemapSpider
|
||||
|
|
@ -253,6 +275,46 @@ Sitemap: /sitemap-relative-url.xml
|
|||
urls = [req.url for req in spider._parse_sitemap(r)]
|
||||
assert urls == result
|
||||
|
||||
@coroutine_test
|
||||
async def test_sitemap_rules_with_callable(self, mockserver: MockServer):
|
||||
# A sitemap_rules entry may hold a callable instead of a method name.
|
||||
def parse_item(response):
|
||||
yield {"url": response.url}
|
||||
|
||||
class _Spider(RawSitemapSpider, self.spider_class): # type: ignore[name-defined,misc]
|
||||
sitemap_rules = [("", parse_item)]
|
||||
|
||||
def raw_body(self):
|
||||
loc = self.mockserver.url("/text")
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
|
||||
f"<url><loc>{loc}</loc></url>"
|
||||
"</urlset>"
|
||||
)
|
||||
|
||||
items, _ = await crawl_items(_Spider, mockserver)
|
||||
assert items == [{"url": mockserver.url("/text")}]
|
||||
|
||||
@coroutine_test
|
||||
async def test_sitemap_empty_loc(self, mockserver: MockServer):
|
||||
class _Spider(RawSitemapSpider, self.spider_class): # type: ignore[name-defined,misc]
|
||||
def parse(self, response):
|
||||
yield {"url": response.url}
|
||||
|
||||
def raw_body(self):
|
||||
loc = self.mockserver.url("/text")
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
|
||||
"<url><loc></loc></url>"
|
||||
f"<url><loc>{loc}</loc></url>"
|
||||
"</urlset>"
|
||||
)
|
||||
|
||||
items, _ = await crawl_items(_Spider, mockserver)
|
||||
assert items == [{"url": mockserver.url("/text")}]
|
||||
|
||||
def test_parse_sitemap_empty_body(self, caplog: pytest.LogCaptureFixture) -> None:
|
||||
r = XmlResponse(url="http://www.example.com/sitemap.xml", body=b"")
|
||||
spider = self.spider_class("example.com")
|
||||
|
|
|
|||
|
|
@ -1,16 +1,18 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from scrapy.utils.asyncgen import as_async_generator, collect_asyncgen
|
||||
from tests.utils.decorators import coroutine_test
|
||||
|
||||
|
||||
class TestAsyncgenUtils:
|
||||
@coroutine_test
|
||||
async def test_as_async_generator(self):
|
||||
ag = as_async_generator(range(42))
|
||||
results = [i async for i in ag]
|
||||
assert results == list(range(42))
|
||||
@coroutine_test
|
||||
async def test_as_async_generator():
|
||||
ag = as_async_generator(range(42))
|
||||
results = [i async for i in ag]
|
||||
assert results == list(range(42))
|
||||
|
||||
@coroutine_test
|
||||
async def test_collect_asyncgen(self):
|
||||
ag = as_async_generator(range(42))
|
||||
results = await collect_asyncgen(ag)
|
||||
assert results == list(range(42))
|
||||
|
||||
@coroutine_test
|
||||
async def test_collect_asyncgen():
|
||||
ag = as_async_generator(range(42))
|
||||
results = await collect_asyncgen(ag)
|
||||
assert results == list(range(42))
|
||||
|
|
|
|||
|
|
@ -20,11 +20,10 @@ if TYPE_CHECKING:
|
|||
from collections.abc import AsyncGenerator
|
||||
|
||||
|
||||
class TestAsyncio:
|
||||
@coroutine_test
|
||||
async def test_is_asyncio_available(self, reactor_pytest: str) -> None:
|
||||
# the result should depend only on the pytest --reactor argument
|
||||
assert is_asyncio_available() == (reactor_pytest != "default")
|
||||
@coroutine_test
|
||||
async def test_is_asyncio_available(reactor_pytest: str) -> None:
|
||||
# the result should depend only on the pytest --reactor argument
|
||||
assert is_asyncio_available() == (reactor_pytest != "default")
|
||||
|
||||
|
||||
@pytest.mark.only_asyncio
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
from typing import Any
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Iterator, Mapping, MutableMapping
|
||||
from typing import Any
|
||||
from typing import Any, Generic, TypeVar
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -16,11 +18,13 @@ from scrapy.utils.datatypes import (
|
|||
)
|
||||
from scrapy.utils.python import garbage_collect
|
||||
|
||||
_DictT = TypeVar("_DictT", bound="CaselessDict | CaseInsensitiveDict")
|
||||
|
||||
class TestCaseInsensitiveDictBase(ABC):
|
||||
|
||||
class TestCaseInsensitiveDictBase(ABC, Generic[_DictT]):
|
||||
@property
|
||||
@abstractmethod
|
||||
def dict_class(self) -> type[MutableMapping[str, Any]]:
|
||||
def dict_class(self) -> type[_DictT]:
|
||||
raise NotImplementedError
|
||||
|
||||
def test_init_dict(self):
|
||||
|
|
@ -36,17 +40,17 @@ class TestCaseInsensitiveDictBase(ABC):
|
|||
assert d["black"] == 3
|
||||
|
||||
def test_init_mapping(self):
|
||||
class MyMapping(Mapping):
|
||||
def __init__(self, **kwargs):
|
||||
class MyMapping(Mapping[str, int]):
|
||||
def __init__(self, **kwargs: int) -> None:
|
||||
self._d = kwargs
|
||||
|
||||
def __getitem__(self, key):
|
||||
def __getitem__(self, key: str) -> int:
|
||||
return self._d[key]
|
||||
|
||||
def __iter__(self):
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
return iter(self._d)
|
||||
|
||||
def __len__(self):
|
||||
def __len__(self) -> int:
|
||||
return len(self._d)
|
||||
|
||||
seq = MyMapping(red=1, black=3)
|
||||
|
|
@ -55,23 +59,23 @@ class TestCaseInsensitiveDictBase(ABC):
|
|||
assert d["black"] == 3
|
||||
|
||||
def test_init_mutable_mapping(self):
|
||||
class MyMutableMapping(MutableMapping):
|
||||
def __init__(self, **kwargs):
|
||||
class MyMutableMapping(MutableMapping[str, int]):
|
||||
def __init__(self, **kwargs: int) -> None:
|
||||
self._d = kwargs
|
||||
|
||||
def __getitem__(self, key):
|
||||
def __getitem__(self, key: str) -> int:
|
||||
return self._d[key]
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
def __setitem__(self, key: str, value: int) -> None:
|
||||
self._d[key] = value
|
||||
|
||||
def __delitem__(self, key):
|
||||
def __delitem__(self, key: str) -> None:
|
||||
del self._d[key]
|
||||
|
||||
def __iter__(self):
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
return iter(self._d)
|
||||
|
||||
def __len__(self):
|
||||
def __len__(self) -> int:
|
||||
return len(self._d)
|
||||
|
||||
seq = MyMutableMapping(red=1, black=3)
|
||||
|
|
@ -149,7 +153,7 @@ class TestCaseInsensitiveDictBase(ABC):
|
|||
d.pop("A")
|
||||
|
||||
def test_normkey(self):
|
||||
class MyDict(self.dict_class):
|
||||
class MyDict(self.dict_class): # type: ignore[misc,name-defined]
|
||||
def _normkey(self, key):
|
||||
return key.title()
|
||||
|
||||
|
|
@ -160,7 +164,7 @@ class TestCaseInsensitiveDictBase(ABC):
|
|||
assert list(d.keys()) == ["Key-One"]
|
||||
|
||||
def test_normvalue(self):
|
||||
class MyDict(self.dict_class):
|
||||
class MyDict(self.dict_class): # type: ignore[misc,name-defined]
|
||||
def _normvalue(self, value):
|
||||
if value is not None:
|
||||
return value + 1
|
||||
|
|
@ -204,9 +208,18 @@ class TestCaseInsensitiveDictBase(ABC):
|
|||
assert h1.get("header1") == h3.get("header1")
|
||||
assert h1.get("header1") == h3.get("HEADER1")
|
||||
|
||||
def test_copy_is_independent(self):
|
||||
h1 = self.dict_class({"header1": "value1", "header2": "value2"})
|
||||
for h2 in (copy.copy(h1), h1.copy()):
|
||||
del h2["header1"]
|
||||
h2["header3"] = "value3"
|
||||
assert "header1" in h1
|
||||
assert "header3" not in h1
|
||||
assert dict(h1) == {"header1": "value1", "header2": "value2"}
|
||||
|
||||
class TestCaseInsensitiveDict(TestCaseInsensitiveDictBase):
|
||||
dict_class = CaseInsensitiveDict # type: ignore[assignment]
|
||||
|
||||
class TestCaseInsensitiveDict(TestCaseInsensitiveDictBase[CaseInsensitiveDict]):
|
||||
dict_class = CaseInsensitiveDict
|
||||
|
||||
def test_repr(self):
|
||||
d1 = self.dict_class({"foo": "bar"})
|
||||
|
|
@ -220,9 +233,31 @@ class TestCaseInsensitiveDict(TestCaseInsensitiveDictBase):
|
|||
assert isinstance(iterkeys, Iterator)
|
||||
assert list(iterkeys) == ["AsDf", "FoO"]
|
||||
|
||||
def test_copy_keeps_values(self):
|
||||
class MyDict(self.dict_class): # type: ignore[misc,name-defined]
|
||||
def _normvalue(self, value):
|
||||
return value + 1
|
||||
|
||||
d = MyDict({"key": 1})
|
||||
for copied in (copy.copy(d), d.copy()):
|
||||
assert copied["key"] == 2
|
||||
|
||||
def test_ior(self):
|
||||
d = self.dict_class({"header1": "value1"})
|
||||
d |= {"HEADER1": "value2", "header2": "value3"}
|
||||
assert len(d) == 2
|
||||
assert d["HeAdEr1"] == "value2"
|
||||
assert d["HeAdEr2"] == "value3"
|
||||
|
||||
def test_ior_mapping(self):
|
||||
d = self.dict_class({"header1": "value1"})
|
||||
d |= self.dict_class({"HEADER1": "value2"})
|
||||
assert len(d) == 1
|
||||
assert d["HeAdEr1"] == "value2"
|
||||
|
||||
|
||||
@pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning")
|
||||
class TestCaselessDict(TestCaseInsensitiveDictBase):
|
||||
class TestCaselessDict(TestCaseInsensitiveDictBase[CaselessDict]):
|
||||
dict_class = CaselessDict
|
||||
|
||||
def test_deprecation_message(self):
|
||||
|
|
@ -288,7 +323,7 @@ class TestSequenceExclude:
|
|||
|
||||
class TestLocalCache:
|
||||
def test_cache_with_limit(self):
|
||||
cache = LocalCache(limit=2)
|
||||
cache: LocalCache[str, int] = LocalCache(limit=2)
|
||||
cache["a"] = 1
|
||||
cache["b"] = 2
|
||||
cache["c"] = 3
|
||||
|
|
@ -301,7 +336,7 @@ class TestLocalCache:
|
|||
|
||||
def test_cache_without_limit(self):
|
||||
maximum = 10**4
|
||||
cache = LocalCache()
|
||||
cache: LocalCache[str, int] = LocalCache()
|
||||
for x in range(maximum):
|
||||
cache[str(x)] = x
|
||||
assert len(cache) == maximum
|
||||
|
|
@ -310,7 +345,7 @@ class TestLocalCache:
|
|||
assert cache[str(x)] == x
|
||||
|
||||
def test_cache_with_zero_limit(self):
|
||||
cache = LocalCache(limit=0)
|
||||
cache: LocalCache[str, int] = LocalCache(limit=0)
|
||||
cache["a"] = 1
|
||||
cache["b"] = 2
|
||||
cache["c"] = 3
|
||||
|
|
@ -322,7 +357,9 @@ class TestLocalCache:
|
|||
|
||||
class TestLocalWeakReferencedCache:
|
||||
def test_cache_with_limit(self):
|
||||
cache = LocalWeakReferencedCache(limit=2)
|
||||
cache: LocalWeakReferencedCache[Request, int] = LocalWeakReferencedCache(
|
||||
limit=2
|
||||
)
|
||||
r1 = Request("https://example.org")
|
||||
r2 = Request("https://example.com")
|
||||
r3 = Request("https://example.net")
|
||||
|
|
@ -344,7 +381,7 @@ class TestLocalWeakReferencedCache:
|
|||
assert len(cache) == 1
|
||||
|
||||
def test_cache_non_weak_referenceable_objects(self):
|
||||
cache = LocalWeakReferencedCache()
|
||||
cache: LocalWeakReferencedCache[Any, int] = LocalWeakReferencedCache()
|
||||
k1 = None
|
||||
k2 = 1
|
||||
k3 = [1, 2, 3]
|
||||
|
|
@ -358,7 +395,7 @@ class TestLocalWeakReferencedCache:
|
|||
|
||||
def test_cache_without_limit(self):
|
||||
maximum = 10**4
|
||||
cache = LocalWeakReferencedCache()
|
||||
cache: LocalWeakReferencedCache[Request, int] = LocalWeakReferencedCache()
|
||||
refs = []
|
||||
for x in range(maximum):
|
||||
refs.append(Request(f"https://example.org/{x}"))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from twisted.internet.defer import Deferred
|
||||
|
|
@ -10,11 +11,14 @@ from scrapy.utils.decorators import _warn_spider_arg, deprecated, inthread
|
|||
from scrapy.utils.defer import maybe_deferred_to_future
|
||||
from tests.utils.decorators import coroutine_test
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
|
||||
class TestDeprecated:
|
||||
def test_warns_and_still_calls(self):
|
||||
@deprecated()
|
||||
def add(a, b):
|
||||
def add(a: int, b: int) -> int:
|
||||
return a + b
|
||||
|
||||
with pytest.warns(
|
||||
|
|
@ -26,7 +30,7 @@ class TestDeprecated:
|
|||
|
||||
def test_use_instead_in_message(self):
|
||||
@deprecated(use_instead="other_function")
|
||||
def old():
|
||||
def old() -> None:
|
||||
return None
|
||||
|
||||
with pytest.warns(
|
||||
|
|
@ -37,7 +41,7 @@ class TestDeprecated:
|
|||
|
||||
def test_applied_without_parentheses(self):
|
||||
@deprecated
|
||||
def square(x):
|
||||
def square(x: int) -> int:
|
||||
return x * x
|
||||
|
||||
with pytest.warns(
|
||||
|
|
@ -65,7 +69,7 @@ class TestInthread:
|
|||
class TestWarnSpiderArg:
|
||||
def test_sync_warns_with_spider_arg(self):
|
||||
@_warn_spider_arg
|
||||
def parse(response, spider=None):
|
||||
def parse(response: str, spider: str | None = None) -> str:
|
||||
return response
|
||||
|
||||
with pytest.warns(
|
||||
|
|
@ -75,7 +79,7 @@ class TestWarnSpiderArg:
|
|||
|
||||
def test_sync_no_warning_without_spider_arg(self):
|
||||
@_warn_spider_arg
|
||||
def parse(response, spider=None):
|
||||
def parse(response: str, spider: str | None = None) -> str:
|
||||
return response
|
||||
|
||||
with warnings.catch_warnings():
|
||||
|
|
@ -85,7 +89,7 @@ class TestWarnSpiderArg:
|
|||
@coroutine_test
|
||||
async def test_async_warns_with_spider_arg(self):
|
||||
@_warn_spider_arg
|
||||
async def parse(response, spider=None):
|
||||
async def parse(response: str, spider: str | None = None) -> str:
|
||||
return response
|
||||
|
||||
with pytest.warns(
|
||||
|
|
@ -96,7 +100,9 @@ class TestWarnSpiderArg:
|
|||
@coroutine_test
|
||||
async def test_asyncgen_warns_with_spider_arg(self):
|
||||
@_warn_spider_arg
|
||||
async def parse(response, spider=None):
|
||||
async def parse(
|
||||
response: str, spider: str | None = None
|
||||
) -> AsyncGenerator[str]:
|
||||
yield response
|
||||
|
||||
with pytest.warns(
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ from tests.utils.decorators import coroutine_test, inline_callbacks_test
|
|||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncGenerator, Awaitable, Callable, Generator
|
||||
|
||||
from twisted.python.failure import Failure
|
||||
|
||||
|
||||
@pytest.mark.requires_reactor # mustbe_deferred() requires a reactor
|
||||
@pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning")
|
||||
|
|
@ -70,7 +72,7 @@ class TestIterErrback:
|
|||
def itergood() -> Generator[int, None, None]:
|
||||
yield from range(10)
|
||||
|
||||
errors = []
|
||||
errors: list[Failure] = []
|
||||
out = list(iter_errback(itergood(), errors.append))
|
||||
assert out == list(range(10))
|
||||
assert not errors
|
||||
|
|
@ -82,7 +84,7 @@ class TestIterErrback:
|
|||
1 / 0
|
||||
yield x
|
||||
|
||||
errors = []
|
||||
errors: list[Failure] = []
|
||||
out = list(iter_errback(iterbad(), errors.append))
|
||||
assert out == [0, 1, 2, 3, 4]
|
||||
assert len(errors) == 1
|
||||
|
|
@ -96,7 +98,7 @@ class TestAiterErrback:
|
|||
for x in range(10):
|
||||
yield x
|
||||
|
||||
errors = []
|
||||
errors: list[Failure] = []
|
||||
out = await collect_asyncgen(aiter_errback(itergood(), errors.append))
|
||||
assert out == list(range(10))
|
||||
assert not errors
|
||||
|
|
@ -109,7 +111,7 @@ class TestAiterErrback:
|
|||
1 / 0
|
||||
yield x
|
||||
|
||||
errors = []
|
||||
errors: list[Failure] = []
|
||||
out = await collect_asyncgen(aiter_errback(iterbad(), errors.append))
|
||||
assert out == [0, 1, 2, 3, 4]
|
||||
assert len(errors) == 1
|
||||
|
|
@ -202,7 +204,7 @@ class TestParallelAsync:
|
|||
for length in [20, 50, 100]:
|
||||
parallel_count = [0]
|
||||
max_parallel_count = [0]
|
||||
results = []
|
||||
results: list[int] = []
|
||||
ait = self.get_async_iterable(length)
|
||||
dl = parallel_async(
|
||||
ait,
|
||||
|
|
@ -222,7 +224,7 @@ class TestParallelAsync:
|
|||
for length in [20, 50, 100]:
|
||||
parallel_count = [0]
|
||||
max_parallel_count = [0]
|
||||
results = []
|
||||
results: list[int] = []
|
||||
ait = self.get_async_iterable_with_delays(length)
|
||||
dl = parallel_async(
|
||||
ait,
|
||||
|
|
@ -240,7 +242,7 @@ class TestParallelAsync:
|
|||
|
||||
class TestDeferredFromCoro:
|
||||
def test_deferred(self):
|
||||
d = Deferred()
|
||||
d: Deferred[None] = Deferred()
|
||||
result = deferred_from_coro(d)
|
||||
assert isinstance(result, Deferred)
|
||||
assert result is d
|
||||
|
|
@ -274,7 +276,7 @@ class TestDeferredFromCoro:
|
|||
@pytest.mark.only_asyncio
|
||||
@inline_callbacks_test
|
||||
def test_future(self):
|
||||
future = Future()
|
||||
future: Future[int] = Future()
|
||||
result = deferred_from_coro(future)
|
||||
assert isinstance(result, Deferred)
|
||||
future.set_result(42)
|
||||
|
|
@ -324,7 +326,7 @@ class TestDeferredFFromCoroF:
|
|||
class TestDeferredToFuture:
|
||||
@coroutine_test
|
||||
async def test_deferred(self):
|
||||
d = Deferred()
|
||||
d: Deferred[int] = Deferred()
|
||||
result = deferred_to_future(d)
|
||||
assert isinstance(result, Future)
|
||||
d.callback(42)
|
||||
|
|
@ -359,7 +361,7 @@ class TestDeferredToFuture:
|
|||
class TestMaybeDeferredToFutureAsyncio:
|
||||
@coroutine_test
|
||||
async def test_deferred(self):
|
||||
d = Deferred()
|
||||
d: Deferred[int] = Deferred()
|
||||
result = maybe_deferred_to_future(d)
|
||||
assert isinstance(result, Future)
|
||||
d.callback(42)
|
||||
|
|
@ -394,7 +396,7 @@ class TestMaybeDeferredToFutureAsyncio:
|
|||
class TestMaybeDeferredToFutureNotAsyncio:
|
||||
@coroutine_test
|
||||
async def test_deferred(self):
|
||||
d = Deferred()
|
||||
d: Deferred[int] = Deferred()
|
||||
result = maybe_deferred_to_future(d)
|
||||
assert isinstance(result, Deferred)
|
||||
assert result is d
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import warnings
|
||||
from unittest import mock
|
||||
|
|
@ -38,7 +40,7 @@ class TestWarnWhenSubclassed:
|
|||
)
|
||||
with pytest.warns(MyWarning, match=msg) as w:
|
||||
|
||||
class UserClass(Deprecated):
|
||||
class UserClass(Deprecated): # type: ignore[misc, valid-type]
|
||||
pass
|
||||
|
||||
assert w[0].lineno == inspect.getsourcelines(UserClass)[1]
|
||||
|
|
@ -57,7 +59,7 @@ class TestWarnWhenSubclassed:
|
|||
match=r"UserClass inherits from deprecated class bar\.OldClass, please inherit from foo\.NewClass",
|
||||
):
|
||||
|
||||
class UserClass(Deprecated):
|
||||
class UserClass(Deprecated): # type: ignore[misc, valid-type]
|
||||
pass
|
||||
|
||||
with pytest.warns(
|
||||
|
|
@ -76,7 +78,7 @@ class TestWarnWhenSubclassed:
|
|||
match="UserClass inherits from deprecated class",
|
||||
):
|
||||
|
||||
class UserClass(Deprecated):
|
||||
class UserClass(Deprecated): # type: ignore[misc, valid-type]
|
||||
pass
|
||||
|
||||
with warnings.catch_warnings():
|
||||
|
|
@ -95,16 +97,16 @@ class TestWarnWhenSubclassed:
|
|||
match="UserClass inherits from deprecated class",
|
||||
):
|
||||
|
||||
class UserClass(Deprecated):
|
||||
class UserClass(Deprecated): # type: ignore[misc, valid-type]
|
||||
pass
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error", MyWarning)
|
||||
|
||||
class FooClass(Deprecated):
|
||||
class FooClass(Deprecated): # type: ignore[misc, valid-type]
|
||||
pass
|
||||
|
||||
class BarClass(Deprecated):
|
||||
class BarClass(Deprecated): # type: ignore[misc, valid-type]
|
||||
pass
|
||||
|
||||
def test_warning_on_instance(self):
|
||||
|
|
@ -112,22 +114,20 @@ class TestWarnWhenSubclassed:
|
|||
"Deprecated", NewName, warn_category=MyWarning
|
||||
)
|
||||
|
||||
with pytest.warns(MyWarning) as w:
|
||||
_, lineno = Deprecated(), inspect.getlineno(inspect.currentframe())
|
||||
|
||||
w = [x for x in w if x.category is MyWarning]
|
||||
with pytest.warns(
|
||||
MyWarning,
|
||||
match=r"tests\.test_utils_deprecate\.Deprecated is deprecated, "
|
||||
r"instantiate tests\.test_utils_deprecate\.NewName instead\.",
|
||||
) as w:
|
||||
_, lineno = Deprecated(), inspect.getlineno(inspect.currentframe()) # type: ignore[arg-type]
|
||||
assert len(w) == 1
|
||||
assert (
|
||||
str(w[0].message) == "tests.test_utils_deprecate.Deprecated is deprecated, "
|
||||
"instantiate tests.test_utils_deprecate.NewName instead."
|
||||
)
|
||||
assert w[0].lineno == lineno
|
||||
|
||||
# ignore subclassing warnings
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", MyWarning)
|
||||
|
||||
class UserClass(Deprecated):
|
||||
class UserClass(Deprecated): # type: ignore[misc, valid-type]
|
||||
pass
|
||||
|
||||
with warnings.catch_warnings():
|
||||
|
|
@ -141,7 +141,7 @@ class TestWarnWhenSubclassed:
|
|||
match=r"UserClass2 inherits from deprecated class tests\.test_utils_deprecate\.Deprecated, please inherit from tests\.test_utils_deprecate\.NewName",
|
||||
):
|
||||
|
||||
class UserClass2(Deprecated):
|
||||
class UserClass2(Deprecated): # type: ignore[misc, valid-type]
|
||||
pass
|
||||
|
||||
def test_issubclass(self):
|
||||
|
|
@ -155,10 +155,10 @@ class TestWarnWhenSubclassed:
|
|||
class UpdatedUserClass1a(NewName):
|
||||
pass
|
||||
|
||||
class OutdatedUserClass1(DeprecatedName):
|
||||
class OutdatedUserClass1(DeprecatedName): # type: ignore[misc, valid-type]
|
||||
pass
|
||||
|
||||
class OutdatedUserClass1a(DeprecatedName):
|
||||
class OutdatedUserClass1a(DeprecatedName): # type: ignore[misc, valid-type]
|
||||
pass
|
||||
|
||||
class UnrelatedClass:
|
||||
|
|
@ -174,7 +174,7 @@ class TestWarnWhenSubclassed:
|
|||
assert not issubclass(OutdatedUserClass1a, OutdatedUserClass1)
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
issubclass(object(), DeprecatedName)
|
||||
issubclass(object(), DeprecatedName) # type: ignore[arg-type]
|
||||
|
||||
def test_isinstance(self):
|
||||
with warnings.catch_warnings():
|
||||
|
|
@ -187,10 +187,10 @@ class TestWarnWhenSubclassed:
|
|||
class UpdatedUserClass2a(NewName):
|
||||
pass
|
||||
|
||||
class OutdatedUserClass2(DeprecatedName):
|
||||
class OutdatedUserClass2(DeprecatedName): # type: ignore[misc, valid-type]
|
||||
pass
|
||||
|
||||
class OutdatedUserClass2a(DeprecatedName):
|
||||
class OutdatedUserClass2a(DeprecatedName): # type: ignore[misc, valid-type]
|
||||
pass
|
||||
|
||||
class UnrelatedClass:
|
||||
|
|
@ -211,7 +211,7 @@ class TestWarnWhenSubclassed:
|
|||
warnings.simplefilter("ignore", ScrapyDeprecationWarning)
|
||||
Deprecated = create_deprecated_class("Deprecated", NewName, {"foo": "bar"})
|
||||
|
||||
assert Deprecated.foo == "bar"
|
||||
assert Deprecated.foo == "bar" # type: ignore[attr-defined]
|
||||
|
||||
def test_deprecate_a_class_with_custom_metaclass(self):
|
||||
Meta1 = type("Meta1", (type,), {})
|
||||
|
|
@ -242,7 +242,7 @@ class TestWarnWhenSubclassed:
|
|||
match=r"UserClass inherits from deprecated class tests\.test_utils_deprecate\.AlsoDeprecated, please inherit from foo\.Bar",
|
||||
):
|
||||
|
||||
class UserClass(AlsoDeprecated):
|
||||
class UserClass(AlsoDeprecated): # type: ignore[misc, valid-type]
|
||||
pass
|
||||
|
||||
def test_inspect_stack(self):
|
||||
|
|
|
|||
|
|
@ -31,13 +31,13 @@ plain_string = "{'a': 1}"
|
|||
|
||||
@mock.patch("sys.platform", "linux")
|
||||
@mock.patch("sys.stdout.isatty")
|
||||
def test_pformat(isatty):
|
||||
def test_pformat(isatty: mock.Mock) -> None:
|
||||
isatty.return_value = True
|
||||
assert pformat(value) in colorized_strings
|
||||
|
||||
|
||||
@mock.patch("sys.stdout.isatty")
|
||||
def test_pformat_dont_colorize(isatty):
|
||||
def test_pformat_dont_colorize(isatty: mock.Mock) -> None:
|
||||
isatty.return_value = True
|
||||
assert pformat(value, colorize=False) == plain_string
|
||||
|
||||
|
|
@ -49,7 +49,7 @@ def test_pformat_not_tty():
|
|||
@mock.patch("sys.platform", "win32")
|
||||
@mock.patch("platform.version")
|
||||
@mock.patch("sys.stdout.isatty")
|
||||
def test_pformat_old_windows(isatty, version):
|
||||
def test_pformat_old_windows(isatty: mock.Mock, version: mock.Mock) -> None:
|
||||
isatty.return_value = True
|
||||
version.return_value = "10.0.14392"
|
||||
assert pformat(value) in colorized_strings
|
||||
|
|
@ -59,7 +59,9 @@ def test_pformat_old_windows(isatty, version):
|
|||
@mock.patch("scrapy.utils.display._enable_windows_terminal_processing")
|
||||
@mock.patch("platform.version")
|
||||
@mock.patch("sys.stdout.isatty")
|
||||
def test_pformat_windows_no_terminal_processing(isatty, version, terminal_processing):
|
||||
def test_pformat_windows_no_terminal_processing(
|
||||
isatty: mock.Mock, version: mock.Mock, terminal_processing: mock.Mock
|
||||
) -> None:
|
||||
isatty.return_value = True
|
||||
version.return_value = "10.0.14393"
|
||||
terminal_processing.return_value = False
|
||||
|
|
@ -70,7 +72,9 @@ def test_pformat_windows_no_terminal_processing(isatty, version, terminal_proces
|
|||
@mock.patch("scrapy.utils.display._enable_windows_terminal_processing")
|
||||
@mock.patch("platform.version")
|
||||
@mock.patch("sys.stdout.isatty")
|
||||
def test_pformat_windows(isatty, version, terminal_processing):
|
||||
def test_pformat_windows(
|
||||
isatty: mock.Mock, version: mock.Mock, terminal_processing: mock.Mock
|
||||
) -> None:
|
||||
isatty.return_value = True
|
||||
version.return_value = "10.0.14393"
|
||||
terminal_processing.return_value = True
|
||||
|
|
@ -79,7 +83,7 @@ def test_pformat_windows(isatty, version, terminal_processing):
|
|||
|
||||
@mock.patch("sys.platform", "linux")
|
||||
@mock.patch("sys.stdout.isatty")
|
||||
def test_pformat_no_pygments(isatty):
|
||||
def test_pformat_no_pygments(isatty: mock.Mock) -> None:
|
||||
isatty.return_value = True
|
||||
|
||||
real_import = builtins.__import__
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from gzip import BadGzipFile
|
||||
from pathlib import Path
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from scrapy.http import Request
|
||||
|
|
|
|||
|
|
@ -20,150 +20,157 @@ from scrapy.utils.misc import (
|
|||
)
|
||||
|
||||
|
||||
class TestUtilsMisc:
|
||||
def test_load_object_class(self):
|
||||
obj = load_object(Field)
|
||||
assert obj is Field
|
||||
obj = load_object("scrapy.item.Field")
|
||||
assert obj is Field
|
||||
def test_load_object_class() -> None:
|
||||
obj = load_object(Field)
|
||||
assert obj is Field
|
||||
obj = load_object("scrapy.item.Field")
|
||||
assert obj is Field
|
||||
|
||||
def test_load_object_function(self):
|
||||
obj = load_object(load_object)
|
||||
assert obj is load_object
|
||||
obj = load_object("scrapy.utils.misc.load_object")
|
||||
assert obj is load_object
|
||||
|
||||
def test_load_object_exceptions(self):
|
||||
with pytest.raises(ImportError):
|
||||
load_object("nomodule999.mod.function")
|
||||
with pytest.raises(NameError):
|
||||
load_object("scrapy.utils.misc.load_object999")
|
||||
with pytest.raises(TypeError):
|
||||
load_object({}) # type: ignore[arg-type]
|
||||
def test_load_object_function() -> None:
|
||||
obj = load_object(load_object)
|
||||
assert obj is load_object
|
||||
obj = load_object("scrapy.utils.misc.load_object")
|
||||
assert obj is load_object
|
||||
|
||||
def test_walk_modules(self):
|
||||
mods = walk_modules_iter("tests.test_utils_misc.test_walk_modules")
|
||||
|
||||
def test_load_object_exceptions() -> None:
|
||||
with pytest.raises(ImportError):
|
||||
load_object("nomodule999.mod.function")
|
||||
with pytest.raises(NameError):
|
||||
load_object("scrapy.utils.misc.load_object999")
|
||||
with pytest.raises(TypeError):
|
||||
load_object({}) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_walk_modules() -> None:
|
||||
mods = walk_modules_iter("tests.test_utils_misc.test_walk_modules")
|
||||
expected = [
|
||||
"tests.test_utils_misc.test_walk_modules",
|
||||
"tests.test_utils_misc.test_walk_modules.mod",
|
||||
"tests.test_utils_misc.test_walk_modules.mod.mod0",
|
||||
"tests.test_utils_misc.test_walk_modules.mod1",
|
||||
]
|
||||
assert {m.__name__ for m in mods} == set(expected)
|
||||
|
||||
mods = walk_modules_iter("tests.test_utils_misc.test_walk_modules.mod")
|
||||
expected = [
|
||||
"tests.test_utils_misc.test_walk_modules.mod",
|
||||
"tests.test_utils_misc.test_walk_modules.mod.mod0",
|
||||
]
|
||||
assert {m.__name__ for m in mods} == set(expected)
|
||||
|
||||
mods = walk_modules_iter("tests.test_utils_misc.test_walk_modules.mod1")
|
||||
expected = [
|
||||
"tests.test_utils_misc.test_walk_modules.mod1",
|
||||
]
|
||||
assert {m.__name__ for m in mods} == set(expected)
|
||||
|
||||
with pytest.raises(ImportError):
|
||||
for _ in walk_modules_iter("nomodule999"):
|
||||
pass
|
||||
with (
|
||||
pytest.raises(ImportError),
|
||||
pytest.warns(
|
||||
ScrapyDeprecationWarning,
|
||||
match="The scrapy.utils.misc.walk_modules function is deprecated and will be "
|
||||
"removed in a future version of Scrapy. "
|
||||
"Use scrapy.utils.misc.walk_modules_iter instead.",
|
||||
),
|
||||
):
|
||||
walk_modules("nomodule999")
|
||||
|
||||
|
||||
def test_walk_modules_egg() -> None:
|
||||
egg = str(Path(__file__).parent / "test.egg")
|
||||
sys.path.append(egg)
|
||||
try:
|
||||
mods = walk_modules_iter("testegg")
|
||||
expected = [
|
||||
"tests.test_utils_misc.test_walk_modules",
|
||||
"tests.test_utils_misc.test_walk_modules.mod",
|
||||
"tests.test_utils_misc.test_walk_modules.mod.mod0",
|
||||
"tests.test_utils_misc.test_walk_modules.mod1",
|
||||
"testegg.spiders",
|
||||
"testegg.spiders.a",
|
||||
"testegg.spiders.b",
|
||||
"testegg",
|
||||
]
|
||||
assert {m.__name__ for m in mods} == set(expected)
|
||||
finally:
|
||||
sys.path.remove(egg)
|
||||
|
||||
mods = walk_modules_iter("tests.test_utils_misc.test_walk_modules.mod")
|
||||
expected = [
|
||||
"tests.test_utils_misc.test_walk_modules.mod",
|
||||
"tests.test_utils_misc.test_walk_modules.mod.mod0",
|
||||
]
|
||||
assert {m.__name__ for m in mods} == set(expected)
|
||||
|
||||
mods = walk_modules_iter("tests.test_utils_misc.test_walk_modules.mod1")
|
||||
expected = [
|
||||
"tests.test_utils_misc.test_walk_modules.mod1",
|
||||
]
|
||||
assert {m.__name__ for m in mods} == set(expected)
|
||||
def test_arg_to_iter() -> None:
|
||||
class TestItem(Item):
|
||||
name = Field()
|
||||
|
||||
with pytest.raises(ImportError):
|
||||
for _ in walk_modules_iter("nomodule999"):
|
||||
pass
|
||||
with (
|
||||
pytest.raises(ImportError),
|
||||
pytest.warns(
|
||||
ScrapyDeprecationWarning,
|
||||
match="The scrapy.utils.misc.walk_modules function is deprecated and will be "
|
||||
"removed in a future version of Scrapy. "
|
||||
"Use scrapy.utils.misc.walk_modules_iter instead.",
|
||||
),
|
||||
):
|
||||
walk_modules("nomodule999")
|
||||
assert hasattr(arg_to_iter(None), "__iter__")
|
||||
assert hasattr(arg_to_iter(100), "__iter__")
|
||||
assert hasattr(arg_to_iter("lala"), "__iter__")
|
||||
assert hasattr(arg_to_iter([1, 2, 3]), "__iter__")
|
||||
assert hasattr(arg_to_iter(c for c in "abcd"), "__iter__")
|
||||
|
||||
def test_walk_modules_egg(self):
|
||||
egg = str(Path(__file__).parent / "test.egg")
|
||||
sys.path.append(egg)
|
||||
try:
|
||||
mods = walk_modules_iter("testegg")
|
||||
expected = [
|
||||
"testegg.spiders",
|
||||
"testegg.spiders.a",
|
||||
"testegg.spiders.b",
|
||||
"testegg",
|
||||
]
|
||||
assert {m.__name__ for m in mods} == set(expected)
|
||||
finally:
|
||||
sys.path.remove(egg)
|
||||
assert not list(arg_to_iter(None))
|
||||
assert list(arg_to_iter("lala")) == ["lala"]
|
||||
assert list(arg_to_iter(100)) == [100]
|
||||
assert list(arg_to_iter(c for c in "abc")) == ["a", "b", "c"]
|
||||
assert list(arg_to_iter([1, 2, 3])) == [1, 2, 3]
|
||||
assert list(arg_to_iter({"a": 1})) == [{"a": 1}]
|
||||
assert list(arg_to_iter(TestItem(name="john"))) == [TestItem(name="john")]
|
||||
|
||||
def test_arg_to_iter(self):
|
||||
class TestItem(Item):
|
||||
name = Field()
|
||||
|
||||
assert hasattr(arg_to_iter(None), "__iter__")
|
||||
assert hasattr(arg_to_iter(100), "__iter__")
|
||||
assert hasattr(arg_to_iter("lala"), "__iter__")
|
||||
assert hasattr(arg_to_iter([1, 2, 3]), "__iter__")
|
||||
assert hasattr(arg_to_iter(c for c in "abcd"), "__iter__")
|
||||
def test_build_from_crawler() -> None:
|
||||
crawler = mock.MagicMock(spec_set=["settings"])
|
||||
args = (True, 100.0)
|
||||
kwargs = {"key": "val"}
|
||||
|
||||
assert not list(arg_to_iter(None))
|
||||
assert list(arg_to_iter("lala")) == ["lala"]
|
||||
assert list(arg_to_iter(100)) == [100]
|
||||
assert list(arg_to_iter(c for c in "abc")) == ["a", "b", "c"]
|
||||
assert list(arg_to_iter([1, 2, 3])) == [1, 2, 3]
|
||||
assert list(arg_to_iter({"a": 1})) == [{"a": 1}]
|
||||
assert list(arg_to_iter(TestItem(name="john"))) == [TestItem(name="john")]
|
||||
def _test_with_crawler(mock: mock.MagicMock, crawler: mock.MagicMock) -> None:
|
||||
build_from_crawler(mock, crawler, *args, **kwargs)
|
||||
if hasattr(mock, "from_crawler"):
|
||||
mock.from_crawler.assert_called_once_with(crawler, *args, **kwargs)
|
||||
assert mock.call_count == 0
|
||||
else:
|
||||
mock.assert_called_once_with(*args, **kwargs)
|
||||
|
||||
def test_build_from_crawler(self):
|
||||
crawler = mock.MagicMock(spec_set=["settings"])
|
||||
args = (True, 100.0)
|
||||
kwargs = {"key": "val"}
|
||||
# Check usage of correct constructor using 2 mocks:
|
||||
# 1. with no alternative constructors
|
||||
# 2. with from_crawler() constructor
|
||||
spec_sets = (
|
||||
["__qualname__"],
|
||||
["__qualname__", "from_crawler"],
|
||||
)
|
||||
for specs in spec_sets:
|
||||
m = mock.MagicMock(spec_set=specs)
|
||||
_test_with_crawler(m, crawler)
|
||||
m.reset_mock()
|
||||
|
||||
def _test_with_crawler(mock: mock.MagicMock, crawler: mock.MagicMock) -> None:
|
||||
build_from_crawler(mock, crawler, *args, **kwargs)
|
||||
if hasattr(mock, "from_crawler"):
|
||||
mock.from_crawler.assert_called_once_with(crawler, *args, **kwargs)
|
||||
assert mock.call_count == 0
|
||||
else:
|
||||
mock.assert_called_once_with(*args, **kwargs)
|
||||
# Check adoption of crawler
|
||||
m = mock.MagicMock(spec_set=["__qualname__", "from_crawler"])
|
||||
m.from_crawler.return_value = None
|
||||
with pytest.raises(TypeError):
|
||||
build_from_crawler(m, crawler, *args, **kwargs)
|
||||
|
||||
# Check usage of correct constructor using 2 mocks:
|
||||
# 1. with no alternative constructors
|
||||
# 2. with from_crawler() constructor
|
||||
spec_sets = (
|
||||
["__qualname__"],
|
||||
["__qualname__", "from_crawler"],
|
||||
)
|
||||
for specs in spec_sets:
|
||||
m = mock.MagicMock(spec_set=specs)
|
||||
_test_with_crawler(m, crawler)
|
||||
m.reset_mock()
|
||||
|
||||
# Check adoption of crawler
|
||||
m = mock.MagicMock(spec_set=["__qualname__", "from_crawler"])
|
||||
m.from_crawler.return_value = None
|
||||
with pytest.raises(TypeError):
|
||||
build_from_crawler(m, crawler, *args, **kwargs)
|
||||
def test_set_environ() -> None:
|
||||
assert os.environ.get("some_test_environ") is None
|
||||
with set_environ(some_test_environ="test_value"):
|
||||
assert os.environ.get("some_test_environ") == "test_value"
|
||||
assert os.environ.get("some_test_environ") is None
|
||||
|
||||
def test_set_environ(self):
|
||||
assert os.environ.get("some_test_environ") is None
|
||||
with set_environ(some_test_environ="test_value"):
|
||||
assert os.environ.get("some_test_environ") == "test_value"
|
||||
assert os.environ.get("some_test_environ") is None
|
||||
os.environ["some_test_environ"] = "test"
|
||||
assert os.environ.get("some_test_environ") == "test"
|
||||
with set_environ(some_test_environ="test_value"):
|
||||
assert os.environ.get("some_test_environ") == "test_value"
|
||||
assert os.environ.get("some_test_environ") == "test"
|
||||
|
||||
os.environ["some_test_environ"] = "test"
|
||||
assert os.environ.get("some_test_environ") == "test"
|
||||
with set_environ(some_test_environ="test_value"):
|
||||
assert os.environ.get("some_test_environ") == "test_value"
|
||||
assert os.environ.get("some_test_environ") == "test"
|
||||
|
||||
def test_rel_has_nofollow(self):
|
||||
assert rel_has_nofollow("ugc nofollow") is True
|
||||
assert rel_has_nofollow("ugc,nofollow") is True
|
||||
assert rel_has_nofollow("ugc") is False
|
||||
assert rel_has_nofollow("nofollow") is True
|
||||
assert rel_has_nofollow("nofollowfoo") is False
|
||||
assert rel_has_nofollow("foonofollow") is False
|
||||
assert rel_has_nofollow("ugc, , nofollow") is True
|
||||
# rel attribute values are ASCII case-insensitive per the HTML spec
|
||||
assert rel_has_nofollow("NoFollow") is True
|
||||
assert rel_has_nofollow("NOFOLLOW") is True
|
||||
assert rel_has_nofollow("UGC NoFollow") is True
|
||||
assert rel_has_nofollow("ugc,NoFollow") is True
|
||||
def test_rel_has_nofollow() -> None:
|
||||
assert rel_has_nofollow("ugc nofollow") is True
|
||||
assert rel_has_nofollow("ugc,nofollow") is True
|
||||
assert rel_has_nofollow("ugc") is False
|
||||
assert rel_has_nofollow("nofollow") is True
|
||||
assert rel_has_nofollow("nofollowfoo") is False
|
||||
assert rel_has_nofollow("foonofollow") is False
|
||||
assert rel_has_nofollow("ugc, , nofollow") is True
|
||||
# rel attribute values are ASCII case-insensitive per the HTML spec
|
||||
assert rel_has_nofollow("NoFollow") is True
|
||||
assert rel_has_nofollow("NOFOLLOW") is True
|
||||
assert rel_has_nofollow("UGC NoFollow") is True
|
||||
assert rel_has_nofollow("ugc,NoFollow") is True
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
from functools import partial
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
|
@ -9,6 +12,11 @@ from scrapy.utils.misc import (
|
|||
warn_on_generator_with_return_value,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Generator
|
||||
|
||||
from scrapy import Spider
|
||||
|
||||
|
||||
def _indentation_error(*args, **kwargs):
|
||||
raise IndentationError
|
||||
|
|
@ -35,244 +43,239 @@ https://example.org
|
|||
yield url
|
||||
|
||||
|
||||
def generator_that_returns_stuff():
|
||||
def generator_that_returns_stuff() -> Generator[int, None, int]:
|
||||
yield 1
|
||||
yield 2
|
||||
return 3
|
||||
|
||||
|
||||
class TestUtilsMisc:
|
||||
@pytest.fixture
|
||||
def mock_spider(self):
|
||||
class MockSettings:
|
||||
def __init__(self, settings_dict=None):
|
||||
self.settings_dict = settings_dict or {
|
||||
"WARN_ON_GENERATOR_RETURN_VALUE": True
|
||||
}
|
||||
@pytest.fixture
|
||||
def mock_spider() -> Spider:
|
||||
class MockSettings:
|
||||
def __init__(self, settings_dict: dict[str, Any] | None = None):
|
||||
self.settings_dict = settings_dict or {
|
||||
"WARN_ON_GENERATOR_RETURN_VALUE": True
|
||||
}
|
||||
|
||||
def getbool(self, name, default=False):
|
||||
return self.settings_dict.get(name, default)
|
||||
def getbool(self, name, default=False):
|
||||
return self.settings_dict.get(name, default)
|
||||
|
||||
class MockSpider:
|
||||
def __init__(self):
|
||||
self.settings = MockSettings()
|
||||
class MockSpider:
|
||||
def __init__(self) -> None:
|
||||
self.settings = MockSettings()
|
||||
|
||||
return MockSpider()
|
||||
return MockSpider() # type: ignore[return-value]
|
||||
|
||||
def test_generators_return_something(self, mock_spider):
|
||||
def f1():
|
||||
yield 1
|
||||
return 2
|
||||
|
||||
def g1():
|
||||
yield 1
|
||||
return "asdf"
|
||||
def test_generators_return_something(mock_spider):
|
||||
def f1():
|
||||
yield 1
|
||||
return 2
|
||||
|
||||
def h1():
|
||||
yield 1
|
||||
def g1():
|
||||
yield 1
|
||||
return "asdf"
|
||||
|
||||
def helper():
|
||||
return 0
|
||||
def h1():
|
||||
yield 1
|
||||
|
||||
yield helper()
|
||||
return 2
|
||||
def helper() -> int:
|
||||
return 0
|
||||
|
||||
def i1():
|
||||
"""
|
||||
docstring
|
||||
"""
|
||||
url = """
|
||||
https://example.org
|
||||
yield helper()
|
||||
return 2
|
||||
|
||||
def i1():
|
||||
"""
|
||||
yield url
|
||||
return 1
|
||||
|
||||
assert is_generator_with_return_value(top_level_return_something)
|
||||
assert is_generator_with_return_value(f1)
|
||||
assert is_generator_with_return_value(g1)
|
||||
assert is_generator_with_return_value(h1)
|
||||
assert is_generator_with_return_value(i1)
|
||||
|
||||
with pytest.warns(
|
||||
UserWarning,
|
||||
match='The "MockSpider.top_level_return_something" method is a generator',
|
||||
):
|
||||
warn_on_generator_with_return_value(mock_spider, top_level_return_something)
|
||||
with pytest.warns(
|
||||
UserWarning, match='The "MockSpider.f1" method is a generator'
|
||||
):
|
||||
warn_on_generator_with_return_value(mock_spider, f1)
|
||||
with pytest.warns(
|
||||
UserWarning, match='The "MockSpider.g1" method is a generator'
|
||||
):
|
||||
warn_on_generator_with_return_value(mock_spider, g1)
|
||||
with pytest.warns(
|
||||
UserWarning, match='The "MockSpider.h1" method is a generator'
|
||||
):
|
||||
warn_on_generator_with_return_value(mock_spider, h1)
|
||||
with pytest.warns(
|
||||
UserWarning, match='The "MockSpider.i1" method is a generator'
|
||||
):
|
||||
warn_on_generator_with_return_value(mock_spider, i1)
|
||||
|
||||
def test_generators_return_none(self, mock_spider):
|
||||
def f2():
|
||||
yield 1
|
||||
|
||||
def g2():
|
||||
yield 1
|
||||
|
||||
def h2():
|
||||
yield 1
|
||||
|
||||
def i2():
|
||||
yield 1
|
||||
yield from generator_that_returns_stuff()
|
||||
|
||||
def j2():
|
||||
yield 1
|
||||
|
||||
def helper():
|
||||
return 0
|
||||
|
||||
yield helper()
|
||||
|
||||
def k2():
|
||||
"""
|
||||
docstring
|
||||
"""
|
||||
url = """
|
||||
https://example.org
|
||||
docstring
|
||||
"""
|
||||
yield url
|
||||
|
||||
def l2():
|
||||
return
|
||||
|
||||
assert not is_generator_with_return_value(top_level_return_none)
|
||||
assert not is_generator_with_return_value(f2)
|
||||
assert not is_generator_with_return_value(g2)
|
||||
assert not is_generator_with_return_value(h2)
|
||||
assert not is_generator_with_return_value(i2)
|
||||
assert not is_generator_with_return_value(j2) # not recursive
|
||||
assert not is_generator_with_return_value(k2) # not recursive
|
||||
assert not is_generator_with_return_value(l2)
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error", UserWarning)
|
||||
warn_on_generator_with_return_value(mock_spider, top_level_return_none)
|
||||
warn_on_generator_with_return_value(mock_spider, f2)
|
||||
warn_on_generator_with_return_value(mock_spider, g2)
|
||||
warn_on_generator_with_return_value(mock_spider, h2)
|
||||
warn_on_generator_with_return_value(mock_spider, i2)
|
||||
warn_on_generator_with_return_value(mock_spider, j2)
|
||||
warn_on_generator_with_return_value(mock_spider, k2)
|
||||
warn_on_generator_with_return_value(mock_spider, l2)
|
||||
|
||||
def test_generators_return_none_with_decorator(self, mock_spider):
|
||||
def decorator(func):
|
||||
def inner_func():
|
||||
func()
|
||||
|
||||
return inner_func
|
||||
|
||||
@decorator
|
||||
def f3():
|
||||
yield 1
|
||||
|
||||
@decorator
|
||||
def g3():
|
||||
yield 1
|
||||
|
||||
@decorator
|
||||
def h3():
|
||||
yield 1
|
||||
|
||||
@decorator
|
||||
def i3():
|
||||
yield 1
|
||||
yield from generator_that_returns_stuff()
|
||||
|
||||
@decorator
|
||||
def j3():
|
||||
yield 1
|
||||
|
||||
def helper():
|
||||
return 0
|
||||
|
||||
yield helper()
|
||||
|
||||
@decorator
|
||||
def k3():
|
||||
"""
|
||||
docstring
|
||||
"""
|
||||
url = """
|
||||
url = """
|
||||
https://example.org
|
||||
"""
|
||||
yield url
|
||||
return 1
|
||||
|
||||
assert is_generator_with_return_value(top_level_return_something)
|
||||
assert is_generator_with_return_value(f1)
|
||||
assert is_generator_with_return_value(g1)
|
||||
assert is_generator_with_return_value(h1)
|
||||
assert is_generator_with_return_value(i1)
|
||||
|
||||
with pytest.warns(
|
||||
UserWarning,
|
||||
match='The "MockSpider.top_level_return_something" method is a generator',
|
||||
):
|
||||
warn_on_generator_with_return_value(mock_spider, top_level_return_something)
|
||||
with pytest.warns(UserWarning, match='The "MockSpider.f1" method is a generator'):
|
||||
warn_on_generator_with_return_value(mock_spider, f1)
|
||||
with pytest.warns(UserWarning, match='The "MockSpider.g1" method is a generator'):
|
||||
warn_on_generator_with_return_value(mock_spider, g1)
|
||||
with pytest.warns(UserWarning, match='The "MockSpider.h1" method is a generator'):
|
||||
warn_on_generator_with_return_value(mock_spider, h1)
|
||||
with pytest.warns(UserWarning, match='The "MockSpider.i1" method is a generator'):
|
||||
warn_on_generator_with_return_value(mock_spider, i1)
|
||||
|
||||
|
||||
def test_generators_return_none(mock_spider):
|
||||
def f2():
|
||||
yield 1
|
||||
|
||||
def g2():
|
||||
yield 1
|
||||
|
||||
def h2():
|
||||
yield 1
|
||||
|
||||
def i2():
|
||||
yield 1
|
||||
yield from generator_that_returns_stuff()
|
||||
|
||||
def j2():
|
||||
yield 1
|
||||
|
||||
def helper() -> int:
|
||||
return 0
|
||||
|
||||
yield helper()
|
||||
|
||||
def k2():
|
||||
"""
|
||||
yield url
|
||||
docstring
|
||||
"""
|
||||
url = """
|
||||
https://example.org
|
||||
"""
|
||||
yield url
|
||||
|
||||
@decorator
|
||||
def l3():
|
||||
return
|
||||
def l2():
|
||||
return
|
||||
|
||||
assert not is_generator_with_return_value(top_level_return_none)
|
||||
assert not is_generator_with_return_value(f3)
|
||||
assert not is_generator_with_return_value(g3)
|
||||
assert not is_generator_with_return_value(h3)
|
||||
assert not is_generator_with_return_value(i3)
|
||||
assert not is_generator_with_return_value(j3) # not recursive
|
||||
assert not is_generator_with_return_value(k3) # not recursive
|
||||
assert not is_generator_with_return_value(l3)
|
||||
assert not is_generator_with_return_value(top_level_return_none)
|
||||
assert not is_generator_with_return_value(f2)
|
||||
assert not is_generator_with_return_value(g2)
|
||||
assert not is_generator_with_return_value(h2)
|
||||
assert not is_generator_with_return_value(i2)
|
||||
assert not is_generator_with_return_value(j2) # not recursive
|
||||
assert not is_generator_with_return_value(k2) # not recursive
|
||||
assert not is_generator_with_return_value(l2)
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error", UserWarning)
|
||||
warn_on_generator_with_return_value(mock_spider, top_level_return_none)
|
||||
warn_on_generator_with_return_value(mock_spider, f3)
|
||||
warn_on_generator_with_return_value(mock_spider, g3)
|
||||
warn_on_generator_with_return_value(mock_spider, h3)
|
||||
warn_on_generator_with_return_value(mock_spider, i3)
|
||||
warn_on_generator_with_return_value(mock_spider, j3)
|
||||
warn_on_generator_with_return_value(mock_spider, k3)
|
||||
warn_on_generator_with_return_value(mock_spider, l3)
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error", UserWarning)
|
||||
warn_on_generator_with_return_value(mock_spider, top_level_return_none)
|
||||
warn_on_generator_with_return_value(mock_spider, f2)
|
||||
warn_on_generator_with_return_value(mock_spider, g2)
|
||||
warn_on_generator_with_return_value(mock_spider, h2)
|
||||
warn_on_generator_with_return_value(mock_spider, i2)
|
||||
warn_on_generator_with_return_value(mock_spider, j2)
|
||||
warn_on_generator_with_return_value(mock_spider, k2)
|
||||
warn_on_generator_with_return_value(mock_spider, l2)
|
||||
|
||||
@mock.patch(
|
||||
"scrapy.utils.misc.is_generator_with_return_value", new=_indentation_error
|
||||
)
|
||||
def test_indentation_error(self, mock_spider):
|
||||
with pytest.warns(UserWarning, match="Unable to determine"):
|
||||
warn_on_generator_with_return_value(mock_spider, top_level_return_none)
|
||||
|
||||
def test_partial(self):
|
||||
def cb(arg1, arg2):
|
||||
yield {}
|
||||
def test_generators_return_none_with_decorator(mock_spider):
|
||||
def decorator(func):
|
||||
def inner_func():
|
||||
func()
|
||||
|
||||
partial_cb = partial(cb, arg1=42)
|
||||
assert not is_generator_with_return_value(partial_cb)
|
||||
return inner_func
|
||||
|
||||
def test_warn_on_generator_with_return_value_settings_disabled(self):
|
||||
class MockSettings:
|
||||
def __init__(self, settings_dict=None):
|
||||
self.settings_dict = settings_dict or {}
|
||||
@decorator
|
||||
def f3():
|
||||
yield 1
|
||||
|
||||
def getbool(self, name, default=False):
|
||||
return self.settings_dict.get(name, default)
|
||||
@decorator
|
||||
def g3():
|
||||
yield 1
|
||||
|
||||
class MockSpider:
|
||||
def __init__(self):
|
||||
self.settings = MockSettings({"WARN_ON_GENERATOR_RETURN_VALUE": False})
|
||||
@decorator
|
||||
def h3():
|
||||
yield 1
|
||||
|
||||
spider = MockSpider()
|
||||
@decorator
|
||||
def i3():
|
||||
yield 1
|
||||
yield from generator_that_returns_stuff()
|
||||
|
||||
def gen_with_return():
|
||||
yield 1
|
||||
return "value"
|
||||
@decorator
|
||||
def j3():
|
||||
yield 1
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error", UserWarning)
|
||||
warn_on_generator_with_return_value(spider, gen_with_return)
|
||||
def helper() -> int:
|
||||
return 0
|
||||
|
||||
spider.settings.settings_dict["WARN_ON_GENERATOR_RETURN_VALUE"] = True
|
||||
yield helper()
|
||||
|
||||
with pytest.warns(UserWarning, match="is a generator"):
|
||||
warn_on_generator_with_return_value(spider, gen_with_return)
|
||||
@decorator
|
||||
def k3():
|
||||
"""
|
||||
docstring
|
||||
"""
|
||||
url = """
|
||||
https://example.org
|
||||
"""
|
||||
yield url
|
||||
|
||||
@decorator
|
||||
def l3():
|
||||
return
|
||||
|
||||
assert not is_generator_with_return_value(top_level_return_none)
|
||||
assert not is_generator_with_return_value(f3)
|
||||
assert not is_generator_with_return_value(g3)
|
||||
assert not is_generator_with_return_value(h3)
|
||||
assert not is_generator_with_return_value(i3)
|
||||
assert not is_generator_with_return_value(j3) # not recursive
|
||||
assert not is_generator_with_return_value(k3) # not recursive
|
||||
assert not is_generator_with_return_value(l3)
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error", UserWarning)
|
||||
warn_on_generator_with_return_value(mock_spider, top_level_return_none)
|
||||
warn_on_generator_with_return_value(mock_spider, f3)
|
||||
warn_on_generator_with_return_value(mock_spider, g3)
|
||||
warn_on_generator_with_return_value(mock_spider, h3)
|
||||
warn_on_generator_with_return_value(mock_spider, i3)
|
||||
warn_on_generator_with_return_value(mock_spider, j3)
|
||||
warn_on_generator_with_return_value(mock_spider, k3)
|
||||
warn_on_generator_with_return_value(mock_spider, l3)
|
||||
|
||||
|
||||
@mock.patch("scrapy.utils.misc.is_generator_with_return_value", new=_indentation_error)
|
||||
def test_indentation_error(mock_spider):
|
||||
with pytest.warns(UserWarning, match="Unable to determine"):
|
||||
warn_on_generator_with_return_value(mock_spider, top_level_return_none)
|
||||
|
||||
|
||||
def test_partial() -> None:
|
||||
def cb(arg1, arg2):
|
||||
yield {}
|
||||
|
||||
partial_cb = partial(cb, arg1=42)
|
||||
assert not is_generator_with_return_value(partial_cb)
|
||||
|
||||
|
||||
def test_warn_on_generator_with_return_value_settings_disabled() -> None:
|
||||
class MockSettings:
|
||||
def __init__(self, settings_dict: dict[str, Any] | None = None):
|
||||
self.settings_dict = settings_dict or {}
|
||||
|
||||
def getbool(self, name, default=False):
|
||||
return self.settings_dict.get(name, default)
|
||||
|
||||
class MockSpider:
|
||||
def __init__(self) -> None:
|
||||
self.settings = MockSettings({"WARN_ON_GENERATOR_RETURN_VALUE": False})
|
||||
|
||||
spider = MockSpider()
|
||||
|
||||
def gen_with_return():
|
||||
yield 1
|
||||
return "value"
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error", UserWarning)
|
||||
warn_on_generator_with_return_value(spider, gen_with_return) # type: ignore[arg-type]
|
||||
|
||||
spider.settings.settings_dict["WARN_ON_GENERATOR_RETURN_VALUE"] = True
|
||||
|
||||
with pytest.warns(UserWarning, match="is a generator"):
|
||||
warn_on_generator_with_return_value(spider, gen_with_return) # type: ignore[arg-type]
|
||||
|
|
|
|||
|
|
@ -1,14 +1,20 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from scrapy.utils.misc import set_environ
|
||||
from scrapy.utils.project import data_path, get_project_settings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Generator
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def proj_path(tmp_path):
|
||||
def proj_path(tmp_path: Path) -> Generator[Path]:
|
||||
prev_dir = Path.cwd()
|
||||
project_dir = tmp_path
|
||||
|
||||
|
|
@ -21,7 +27,7 @@ def proj_path(tmp_path):
|
|||
os.chdir(prev_dir)
|
||||
|
||||
|
||||
def test_data_path_outside_project():
|
||||
def test_data_path_outside_project() -> None:
|
||||
assert str(Path(".scrapy", "somepath")) == data_path("somepath")
|
||||
abspath = str(Path(os.path.sep, "absolute", "path"))
|
||||
assert abspath == data_path(abspath)
|
||||
|
|
|
|||
|
|
@ -22,8 +22,7 @@ from scrapy.utils.python import (
|
|||
from tests.utils.decorators import coroutine_test
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable, Mapping
|
||||
|
||||
from collections.abc import AsyncIterator, Iterable, Mapping
|
||||
|
||||
_KT = TypeVar("_KT")
|
||||
_VT = TypeVar("_VT")
|
||||
|
|
@ -31,22 +30,22 @@ _VT = TypeVar("_VT")
|
|||
|
||||
class TestMutableAsyncChain:
|
||||
@staticmethod
|
||||
async def g1():
|
||||
async def g1() -> AsyncIterator[int]:
|
||||
for i in range(3):
|
||||
yield i
|
||||
|
||||
@staticmethod
|
||||
async def g2():
|
||||
async def g2() -> AsyncIterator[int]:
|
||||
return
|
||||
yield
|
||||
|
||||
@staticmethod
|
||||
async def g3():
|
||||
async def g3() -> AsyncIterator[int]:
|
||||
for i in range(7, 10):
|
||||
yield i
|
||||
|
||||
@staticmethod
|
||||
async def g4():
|
||||
async def g4() -> AsyncIterator[int]:
|
||||
for i in range(3, 5):
|
||||
yield i
|
||||
1 / 0
|
||||
|
|
@ -85,7 +84,7 @@ class TestToUnicode:
|
|||
|
||||
def test_converting_a_strange_object_should_raise_type_error(self):
|
||||
with pytest.raises(TypeError):
|
||||
to_unicode(423)
|
||||
to_unicode(423) # type: ignore[arg-type]
|
||||
|
||||
def test_errors_argument(self):
|
||||
assert to_unicode(b"a\xedb", "utf-8", errors="replace") == "a\ufffdb"
|
||||
|
|
@ -103,7 +102,7 @@ class TestToBytes:
|
|||
|
||||
def test_converting_a_strange_object_should_raise_type_error(self):
|
||||
with pytest.raises(TypeError):
|
||||
to_bytes(pytest)
|
||||
to_bytes(pytest) # type: ignore[arg-type]
|
||||
|
||||
def test_errors_argument(self):
|
||||
assert to_bytes("a\ufffdb", "latin-1", errors="replace") == b"a?b"
|
||||
|
|
@ -112,10 +111,10 @@ class TestToBytes:
|
|||
def test_memoizemethod_noargs():
|
||||
class A:
|
||||
@memoizemethod_noargs
|
||||
def cached(self):
|
||||
def cached(self) -> object:
|
||||
return object()
|
||||
|
||||
def noncached(self):
|
||||
def noncached(self) -> object:
|
||||
return object()
|
||||
|
||||
a = A()
|
||||
|
|
@ -150,7 +149,7 @@ def test_get_func_args():
|
|||
pass
|
||||
|
||||
class A:
|
||||
def __init__(self, a, b, c):
|
||||
def __init__(self, a: int, b: int, c: int):
|
||||
pass
|
||||
|
||||
def method(self, a, b, c):
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
from hashlib import sha1
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any, Protocol
|
||||
from weakref import WeakKeyDictionary
|
||||
|
||||
import pytest
|
||||
|
|
@ -17,6 +17,9 @@ from scrapy.utils.request import (
|
|||
)
|
||||
from scrapy.utils.test import get_crawler
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("r", "expected"),
|
||||
|
|
@ -56,8 +59,18 @@ def test_request_httprepr_for_non_http_request(r: Request) -> None:
|
|||
request_httprepr(r)
|
||||
|
||||
|
||||
class _FingerprintFunction(Protocol):
|
||||
def __call__(
|
||||
self,
|
||||
request: Request,
|
||||
*,
|
||||
include_headers: Iterable[bytes | str] | None = None,
|
||||
keep_fragments: bool = False,
|
||||
) -> bytes: ...
|
||||
|
||||
|
||||
class TestFingerprint:
|
||||
function: staticmethod[[Request], bytes] = staticmethod(fingerprint)
|
||||
function: _FingerprintFunction = staticmethod(fingerprint)
|
||||
cache: (
|
||||
WeakKeyDictionary[
|
||||
Request, dict[tuple[tuple[bytes, ...] | None, bool, bool], bytes]
|
||||
|
|
@ -261,6 +274,7 @@ class TestRequestFingerprinter:
|
|||
def test_fingerprint(self):
|
||||
crawler = get_crawler()
|
||||
request = Request("https://example.com")
|
||||
assert crawler.request_fingerprinter
|
||||
assert crawler.request_fingerprinter.fingerprint(request) == fingerprint(
|
||||
request
|
||||
)
|
||||
|
|
@ -277,6 +291,7 @@ class TestCustomRequestFingerprinter:
|
|||
}
|
||||
crawler = get_crawler(settings_dict=settings)
|
||||
|
||||
assert crawler.request_fingerprinter
|
||||
r1 = Request("http://www.example.com", headers={"X-ID": "1"})
|
||||
fp1 = crawler.request_fingerprinter.fingerprint(r1)
|
||||
r2 = Request("http://www.example.com", headers={"X-ID": "2"})
|
||||
|
|
@ -285,9 +300,9 @@ class TestCustomRequestFingerprinter:
|
|||
|
||||
def test_dont_canonicalize(self):
|
||||
class RequestFingerprinter:
|
||||
cache = WeakKeyDictionary()
|
||||
cache: WeakKeyDictionary[Request, bytes] = WeakKeyDictionary()
|
||||
|
||||
def fingerprint(self, request):
|
||||
def fingerprint(self, request: Request) -> bytes:
|
||||
if request not in self.cache:
|
||||
fp = sha1()
|
||||
fp.update(to_bytes(request.url))
|
||||
|
|
@ -299,6 +314,7 @@ class TestCustomRequestFingerprinter:
|
|||
}
|
||||
crawler = get_crawler(settings_dict=settings)
|
||||
|
||||
assert crawler.request_fingerprinter
|
||||
r1 = Request("http://www.example.com?a=1&a=2")
|
||||
fp1 = crawler.request_fingerprinter.fingerprint(r1)
|
||||
r2 = Request("http://www.example.com?a=2&a=1")
|
||||
|
|
@ -317,6 +333,7 @@ class TestCustomRequestFingerprinter:
|
|||
}
|
||||
crawler = get_crawler(settings_dict=settings)
|
||||
|
||||
assert crawler.request_fingerprinter
|
||||
r1 = Request("http://www.example.com")
|
||||
fp1 = crawler.request_fingerprinter.fingerprint(r1)
|
||||
r2 = Request("http://www.example.com", meta={"fingerprint": "a"})
|
||||
|
|
@ -348,6 +365,7 @@ class TestCustomRequestFingerprinter:
|
|||
}
|
||||
crawler = get_crawler(settings_dict=settings)
|
||||
|
||||
assert crawler.request_fingerprinter
|
||||
request = Request("http://www.example.com")
|
||||
fingerprint = crawler.request_fingerprinter.fingerprint(request)
|
||||
assert fingerprint == settings["FINGERPRINT"]
|
||||
|
|
@ -457,3 +475,14 @@ class TestRequestToCurl:
|
|||
" --data-raw '{\"foo\": \"bar\"}' --cookie 'foo=1'"
|
||||
)
|
||||
self._test_request(request_object, expected_curl_command)
|
||||
|
||||
def test_request_to_curl_method(self) -> None:
|
||||
request_object = Request(
|
||||
"https://www.httpbin.org/post",
|
||||
method="POST",
|
||||
body=json.dumps({"foo": "bar"}),
|
||||
)
|
||||
expected_curl_command = (
|
||||
'curl -X POST https://www.httpbin.org/post --data-raw \'{"foo": "bar"}\''
|
||||
)
|
||||
assert request_object.to_curl() == expected_curl_command
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from time import process_time
|
||||
from urllib.parse import urlparse
|
||||
|
|
@ -15,7 +17,7 @@ from scrapy.utils.response import (
|
|||
)
|
||||
|
||||
|
||||
def _read_browser_output(burl: str):
|
||||
def _read_browser_output(burl: str) -> bytes:
|
||||
path = urlparse(burl).path
|
||||
if not path or not Path(path).exists():
|
||||
path = burl.replace("file://", "")
|
||||
|
|
@ -224,7 +226,7 @@ def test_open_in_browser_redos_head():
|
|||
(b"<!-- <head>fake</head> --><head>real</head>", b"<head>real</head>"),
|
||||
],
|
||||
)
|
||||
def test_remove_html_comments(input_body, output_body):
|
||||
def test_remove_html_comments(input_body: bytes, output_body: bytes) -> None:
|
||||
assert _remove_html_comments(input_body) == output_body
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import datetime
|
||||
import json
|
||||
|
|
|
|||
|
|
@ -21,9 +21,6 @@ from tests.utils.decorators import coroutine_test
|
|||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
|
||||
class TestSendCatchLog:
|
||||
# whether the function being tested returns exceptions or failures
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from scrapy.exceptions import ScrapyDeprecationWarning
|
||||
|
|
|
|||
|
|
@ -1,7 +1,14 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from scrapy.utils.template import render_templatefile
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
def test_simple_render(tmp_path):
|
||||
|
||||
def test_simple_render(tmp_path: Path) -> None:
|
||||
context = {"project_name": "proj", "name": "spi", "classname": "TheSpider"}
|
||||
template = "from ${project_name}.spiders.${name} import ${classname}"
|
||||
rendered = "from proj.spiders.spi import TheSpider"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from io import StringIO
|
||||
from unittest import mock
|
||||
|
|
@ -46,13 +48,13 @@ Bar 1 oldest: 0s ago
|
|||
|
||||
|
||||
@mock.patch("sys.stdout", new_callable=StringIO)
|
||||
def test_print_live_refs_empty(stdout):
|
||||
def test_print_live_refs_empty(stdout: StringIO) -> None:
|
||||
trackref.print_live_refs()
|
||||
assert stdout.getvalue() == "Live References\n\n\n"
|
||||
|
||||
|
||||
@mock.patch("sys.stdout", new_callable=StringIO)
|
||||
def test_print_live_refs_with_objects(stdout):
|
||||
def test_print_live_refs_with_objects(stdout: StringIO) -> None:
|
||||
o1 = Foo() # noqa: F841
|
||||
trackref.print_live_refs()
|
||||
assert (
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from scrapy.linkextractors import IGNORED_EXTENSIONS
|
||||
|
|
@ -191,7 +193,7 @@ def test_guess_scheme(url: str, expected: str):
|
|||
),
|
||||
],
|
||||
)
|
||||
def test_guess_scheme_skipped(url: str, expected: str, reason: str):
|
||||
def test_guess_scheme_skipped(url: str, expected: str, reason: str) -> None:
|
||||
pytest.skip(reason)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -32,3 +32,9 @@ class TestProjectBase:
|
|||
proj_path = tmp_path / self.project_name
|
||||
copytree(_proj_path_cached, proj_path)
|
||||
return proj_path
|
||||
|
||||
@staticmethod
|
||||
def _append_settings(proj_mod_path: Path, text: str) -> None:
|
||||
"""Add text to the end of the project settings.py."""
|
||||
with (proj_mod_path / "settings.py").open("a", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import pytest
|
|||
|
||||
from scrapy.http import Headers, Request
|
||||
from scrapy.http.request import NO_CALLBACK
|
||||
from scrapy.utils.request import request_to_curl
|
||||
|
||||
|
||||
class TestRequestBase(ABC):
|
||||
|
|
@ -488,3 +489,11 @@ class TestRequestBase(ABC):
|
|||
'curl -X PATCH "http://example.org" --foo -z',
|
||||
ignore_unknown_options=False,
|
||||
)
|
||||
|
||||
def test_to_curl(self):
|
||||
# Note: more curated tests regarding curl conversion are in
|
||||
# `test_utils_request.py`
|
||||
r = self.request_class(
|
||||
"http://www.example.com/", method="POST", body=b"foo=bar"
|
||||
)
|
||||
assert r.to_curl() == request_to_curl(r)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from scrapy import signals
|
||||
from scrapy.utils.test import get_crawler
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from scrapy.crawler import Crawler
|
||||
from scrapy.spiders import Spider
|
||||
from tests.mockserver.http import MockServer
|
||||
|
||||
|
||||
async def crawl_items(
|
||||
spider_cls: type[Spider], mockserver: MockServer, **kwargs: Any
|
||||
) -> tuple[list[Any], Crawler]:
|
||||
"""Run *spider_cls* against *mockserver* and return the scraped items along
|
||||
with the crawler, which gives tests access to the resulting stats."""
|
||||
items: list[Any] = []
|
||||
|
||||
def collect(item: Any) -> None:
|
||||
items.append(item)
|
||||
|
||||
crawler = get_crawler(spider_cls)
|
||||
crawler.signals.connect(collect, signals.item_scraped)
|
||||
await crawler.crawl_async(mockserver=mockserver, **kwargs)
|
||||
return items, crawler
|
||||
|
|
@ -3,6 +3,12 @@ from __future__ import annotations
|
|||
from typing import Any
|
||||
|
||||
from scrapy.http.request import NO_CALLBACK, Request
|
||||
from scrapy.pipelines.media import MediaPipeline
|
||||
from scrapy.utils.spider import DefaultSpider
|
||||
|
||||
# required by persist_file() and stat_file(), but as some stores don't use the argument
|
||||
# we can pass this singleton to keep type hints correct
|
||||
DUMMY_SPIDER_INFO = MediaPipeline.SpiderInfo(DefaultSpider())
|
||||
|
||||
|
||||
async def mocked_download_func(request: Request) -> Any:
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ class MyRequest2(Request):
|
|||
|
||||
@pytest.mark.mypy_testing
|
||||
def mypy_test_headers() -> None:
|
||||
Request("data:,", headers=1) # E: Argument "headers" to "Request" has incompatible type "int"; expected "Mapping[str, Any] | Iterable[tuple[str, Any]] | None"
|
||||
Request("data:,", headers=1) # E: Argument "headers" to "Request" has incompatible type "int"; expected "Mapping[str, Any] | Mapping[bytes, Any] | Iterable[tuple[str | bytes, Any]] | None"
|
||||
Request("data:,", headers=None)
|
||||
Request("data:,", headers={})
|
||||
Request("data:,", headers=[])
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from scrapy.http import HtmlResponse, Response, TextResponse
|
|||
|
||||
@pytest.mark.mypy_testing
|
||||
def mypy_test_headers() -> None:
|
||||
Response("data:,", headers=1) # E: Argument "headers" to "Response" has incompatible type "int"; expected "Mapping[str, Any] | Iterable[tuple[str, Any]] | None"
|
||||
Response("data:,", headers=1) # E: Argument "headers" to "Response" has incompatible type "int"; expected "Mapping[str, Any] | Mapping[bytes, Any] | Iterable[tuple[str | bytes, Any]] | None"
|
||||
Response("data:,", headers=None)
|
||||
Response("data:,", headers={})
|
||||
Response("data:,", headers=[])
|
||||
|
|
|
|||
Loading…
Reference in New Issue