diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index a9fc3dd68..1f062eef2 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -4,4 +4,4 @@ e211ec0aa26ecae0da8ae55d064ea60e1efe4d0d # reapplying black to the code with default line length 303f0a70fcf8067adf0a909c2096a5009162383a # reapplying black again and removing line length on pre-commit black config -c5cdd0d30ceb68ccba04af0e71d1b8e6678e2962 \ No newline at end of file +c5cdd0d30ceb68ccba04af0e71d1b8e6678e2962 diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 312af3b2e..101c6648e 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -20,10 +20,10 @@ jobs: - python-version: "3.13" env: TOXENV: pylint - - python-version: "3.9" + - python-version: "3.10" env: TOXENV: typing - - python-version: "3.9" + - python-version: "3.10" env: TOXENV: typing-tests - python-version: "3.13" # Keep in sync with .readthedocs.yml @@ -34,10 +34,10 @@ jobs: TOXENV: twinecheck steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} @@ -50,5 +50,5 @@ jobs: pre-commit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: pre-commit/action@v3.0.1 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d1589f4f7..9894c8e7c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -18,8 +18,8 @@ jobs: permissions: id-token: write steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 with: python-version: "3.13" - run: | diff --git a/.github/workflows/tests-macos.yml b/.github/workflows/tests-macos.yml index d740808cc..f061b1892 100644 --- a/.github/workflows/tests-macos.yml +++ b/.github/workflows/tests-macos.yml @@ -16,13 +16,13 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/tests-ubuntu.yml b/.github/workflows/tests-ubuntu.yml index 06da46ca1..1f1ef77ca 100644 --- a/.github/workflows/tests-ubuntu.yml +++ b/.github/workflows/tests-ubuntu.yml @@ -17,9 +17,6 @@ jobs: fail-fast: false matrix: include: - - python-version: "3.9" - env: - TOXENV: py - python-version: "3.10" env: TOXENV: py @@ -35,27 +32,24 @@ jobs: - python-version: "3.13" env: TOXENV: default-reactor - - python-version: pypy3.10 - env: - TOXENV: pypy3 - python-version: pypy3.11 env: TOXENV: pypy3 # pinned deps - - python-version: "3.9.21" + - python-version: "3.10.19" env: TOXENV: pinned - - python-version: "3.9.21" + - python-version: "3.10.19" env: TOXENV: default-reactor-pinned - - python-version: pypy3.10 + - python-version: pypy3.11 env: TOXENV: pypy3-pinned - - python-version: "3.9.21" + - python-version: "3.10.19" env: TOXENV: extra-deps-pinned - - python-version: "3.9.21" + - python-version: "3.10.19" env: TOXENV: botocore-pinned @@ -68,12 +62,15 @@ jobs: - python-version: "3.13" env: TOXENV: botocore + - python-version: "3.13" + env: + TOXENV: mitmproxy steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml index bbbb704e5..73b87f8ed 100644 --- a/.github/workflows/tests-windows.yml +++ b/.github/workflows/tests-windows.yml @@ -17,9 +17,6 @@ jobs: fail-fast: false matrix: include: - - python-version: "3.9" - env: - TOXENV: py - python-version: "3.10" env: TOXENV: py @@ -37,10 +34,10 @@ jobs: TOXENV: default-reactor # pinned deps - - python-version: "3.9.13" + - python-version: "3.10.11" env: TOXENV: pinned - - python-version: "3.9.13" + - python-version: "3.10.11" env: TOXENV: extra-deps-pinned @@ -49,10 +46,10 @@ jobs: TOXENV: extra-deps steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} diff --git a/.gitignore b/.gitignore index 0a3f0ac1c..4100bcd97 100644 --- a/.gitignore +++ b/.gitignore @@ -5,14 +5,16 @@ _trial_temp* dropin.cache docs/build *egg-info -.tox -venv -build -dist -.idea +.tox/ +venv/ +.venv/ +build/ +dist/ +.idea/ +.vscode/ htmlcov/ -.coverage .pytest_cache/ +.coverage .coverage.* coverage.* *.junit.xml @@ -26,4 +28,4 @@ test-output.* Thumbs.db # OSX miscellaneous -.DS_Store \ No newline at end of file +.DS_Store diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0d1a76247..5ffe2a8ea 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,17 +1,28 @@ +exclude: | + (?x)( + ^docs/_static| + ^docs/_tests| + ^tests/sample_data + ) repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.3 + rev: v0.14.2 hooks: - - id: ruff + - id: ruff-check args: [ --fix ] - id: ruff-format - repo: https://github.com/adamchainz/blacken-docs - rev: 1.19.1 + rev: 1.20.0 hooks: - id: blacken-docs additional_dependencies: - - black==24.10.0 + - black==25.9.0 - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: + - id: end-of-file-fixer - id: trailing-whitespace +- repo: https://github.com/sphinx-contrib/sphinx-lint + rev: v1.0.0 + hooks: + - id: sphinx-lint diff --git a/README.rst b/README.rst index 536dec7f0..6235cb20c 100644 --- a/README.rst +++ b/README.rst @@ -40,7 +40,7 @@ :alt: Ask DeepWiki Scrapy_ is a web scraping framework to extract structured data from websites. -It is cross-platform, and requires Python 3.9+. It is maintained by Zyte_ +It is cross-platform, and requires Python 3.10+. It is maintained by Zyte_ (formerly Scrapinghub) and `many other contributors`_. .. _many other contributors: https://github.com/scrapy/scrapy/graphs/contributors diff --git a/conftest.py b/conftest.py index f952127b9..0dd9d1ee0 100644 --- a/conftest.py +++ b/conftest.py @@ -1,10 +1,17 @@ +from __future__ import annotations + from pathlib import Path +from typing import TYPE_CHECKING import pytest from twisted.web.http import H2_ENABLED -from scrapy.utils.reactor import install_reactor +from scrapy.utils.reactor import set_asyncio_event_loop_policy from tests.keys import generate_keys +from tests.mockserver.http import MockServer + +if TYPE_CHECKING: + from collections.abc import Generator def _py_files(folder): @@ -48,36 +55,30 @@ if not H2_ENABLED: ) -def pytest_addoption(parser): - parser.addoption( - "--reactor", - default="asyncio", - choices=["default", "asyncio"], - ) +@pytest.fixture(scope="session") +def mockserver() -> Generator[MockServer]: + with MockServer() as mockserver: + yield mockserver -@pytest.fixture(scope="class") -def reactor_pytest(request): - if not request.cls: - # doctests - return None - request.cls.reactor_pytest = request.config.getoption("--reactor") - return request.cls.reactor_pytest +@pytest.fixture(scope="session") +def reactor_pytest(request) -> str: + return request.config.getoption("--reactor") @pytest.fixture(autouse=True) def only_asyncio(request, reactor_pytest): - if request.node.get_closest_marker("only_asyncio") and reactor_pytest == "default": - pytest.skip("This test is only run without --reactor=default") + if request.node.get_closest_marker("only_asyncio") and reactor_pytest != "asyncio": + pytest.skip("This test is only run with --reactor=asyncio") @pytest.fixture(autouse=True) def only_not_asyncio(request, reactor_pytest): if ( request.node.get_closest_marker("only_not_asyncio") - and reactor_pytest != "default" + and reactor_pytest == "asyncio" ): - pytest.skip("This test is only run with --reactor=default") + pytest.skip("This test is only run without --reactor=asyncio") @pytest.fixture(autouse=True) @@ -85,7 +86,7 @@ def requires_uvloop(request): if not request.node.get_closest_marker("requires_uvloop"): return try: - import uvloop + import uvloop # noqa: PLC0415 del uvloop except ImportError: @@ -97,7 +98,7 @@ def requires_botocore(request): if not request.node.get_closest_marker("requires_botocore"): return try: - import botocore + import botocore # noqa: PLC0415 del botocore except ImportError: @@ -109,19 +110,28 @@ def requires_boto3(request): if not request.node.get_closest_marker("requires_boto3"): return try: - import boto3 + import boto3 # noqa: PLC0415 del boto3 except ImportError: pytest.skip("boto3 is not installed") +@pytest.fixture(autouse=True) +def requires_mitmproxy(request): + if not request.node.get_closest_marker("requires_mitmproxy"): + return + try: + import mitmproxy # noqa: F401, PLC0415 + except ImportError: + pytest.skip("mitmproxy is not installed") + + def pytest_configure(config): - if config.getoption("--reactor") != "default": - install_reactor("twisted.internet.asyncioreactor.AsyncioSelectorReactor") - else: - # install the reactor explicitly - from twisted.internet import reactor # noqa: F401 + if config.getoption("--reactor") == "asyncio": + # Needed on Windows to switch from proactor to selector for Twisted reactor compatibility. + # If we decide to run tests with both, we will need to add a new option and check it here. + set_asyncio_event_loop_policy() # Generate localhost certificate files, needed by some tests diff --git a/docs/_ext/scrapydocs.py b/docs/_ext/scrapydocs.py index 4ceb003c7..776afaa6c 100644 --- a/docs/_ext/scrapydocs.py +++ b/docs/_ext/scrapydocs.py @@ -29,14 +29,14 @@ def is_setting_index(node: Node) -> bool: if node.tagname == "index" and node["entries"]: # type: ignore[index,attr-defined] # index entries for setting directives look like: # [('pair', 'SETTING_NAME; setting', 'std:setting-SETTING_NAME', '')] - entry_type, info, refid = node["entries"][0][:3] # type: ignore[index] + entry_type, info, _ = node["entries"][0][:3] # type: ignore[index] return entry_type == "pair" and info.endswith("; setting") return False def get_setting_name_and_refid(node: Node) -> tuple[str, str]: """Extract setting name from directive index node""" - entry_type, info, refid = node["entries"][0][:3] # type: ignore[index] + _, info, refid = node["entries"][0][:3] # type: ignore[index] return info.replace("; setting", ""), refid diff --git a/docs/conf.py b/docs/conf.py index 493a62976..9b1db9bb7 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -26,7 +26,6 @@ author = "Scrapy developers" # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration extensions = [ - "hoverxref.extension", "notfound.extension", "scrapydocs", "sphinx.ext.autodoc", @@ -69,6 +68,14 @@ html_css_files = [ "custom.css", ] +html_context = { + "display_github": True, + "github_user": "scrapy", + "github_repo": "scrapy", + "github_version": "master", + "conf_py_path": "/docs/", +} + # Set canonical URL from the Read the Docs Domain html_baseurl = os.environ.get("READTHEDOCS_CANONICAL_URL", "") @@ -119,7 +126,7 @@ coverage_ignore_pyobjects = [ # The interface methods of duplicate request filtering classes are already # covered in the interface documentation part of the DUPEFILTER_CLASS # setting documentation. - r"^scrapy\.dupefilters\.[A-Z]\w*?\.(from_settings|request_seen|open|close|log)$", + r"^scrapy\.dupefilters\.[A-Z]\w*?\.(from_crawler|request_seen|open|close|log)$", # Private exception used by the command-line interface implementation. r"^scrapy\.exceptions\.UsageError", # Methods of BaseItemExporter subclasses are only documented in @@ -157,22 +164,5 @@ intersphinx_mapping = { } intersphinx_disabled_reftypes: Sequence[str] = [] - -# -- Options for sphinx-hoverxref extension ---------------------------------- -# https://sphinx-hoverxref.readthedocs.io/en/latest/configuration.html - -hoverxref_auto_ref = True -hoverxref_role_types = { - "class": "tooltip", - "command": "tooltip", - "confval": "tooltip", - "hoverxref": "tooltip", - "mod": "tooltip", - "ref": "tooltip", - "reqmeta": "tooltip", - "setting": "tooltip", - "signal": "tooltip", -} -hoverxref_roles = ["command", "reqmeta", "setting", "signal"] - +# -- Other options ------------------------------------------------------------ default_dark_mode = False diff --git a/docs/contributing.rst b/docs/contributing.rst index 0172887d6..3976d34c2 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -251,10 +251,10 @@ Coding style Please follow these coding conventions when writing code for inclusion in Scrapy: -* We use `black `_ for code formatting. +* We use `Ruff `_ for code formatting. There is a hook in the pre-commit config that will automatically format your code before every commit. You can also - run black manually with ``tox -e pre-commit``. + run Ruff manually with ``tox -e pre-commit``. * Don't put your name in the code you contribute; git provides enough metadata to identify author of the code. diff --git a/docs/faq.rst b/docs/faq.rst index 1d09a0e63..fdf0582de 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -349,7 +349,7 @@ method for this purpose. For example: class MultiplyItemsMiddleware: - def process_spider_output(self, response, result, spider): + def process_spider_output(self, response, result): for item_or_request in result: if isinstance(item_or_request, Request): continue diff --git a/docs/index.rst b/docs/index.rst index 1a9cf636c..71047f9ef 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -132,7 +132,7 @@ Built-in services topics/telnetconsole :doc:`topics/logging` - Learn how to use Python's builtin logging on Scrapy. + Learn how to use Python's built-in logging on Scrapy. :doc:`topics/stats` Collect statistics about your scraping crawler. diff --git a/docs/intro/install.rst b/docs/intro/install.rst index 488a66f36..0810e7274 100644 --- a/docs/intro/install.rst +++ b/docs/intro/install.rst @@ -9,7 +9,7 @@ Installation guide Supported Python versions ========================= -Scrapy requires Python 3.9+, either the CPython implementation (default) or +Scrapy requires Python 3.10+, either the CPython implementation (default) or the PyPy implementation (see :ref:`python:implementations`). .. _intro-install-scrapy: diff --git a/docs/news.rst b/docs/news.rst index 05ad611ef..96e5cb2b1 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -15,6 +15,156 @@ Backward-incompatible changes ``True`` when running Scrapy via :ref:`its command-line tool ` to avoid a reactor mismatch exception. +- The ``log_count/*`` stats no longer count some of the early messages that + they counted before. While the earliest log messages, emitted before the + counter is initialized, were never counted, the counter initialization now + happens later than in previous Scrapy versions. You may need to adjust + expected values if you retrieve and compare values of these stats in your + code. + (:issue:`7046`) + +- The classes listed below are now :term:`abstract base classes `. They cannot be instantiated directly and their subclasses + need to override the abstract methods listed below to be able to be + instantiated. If you previously instantiated these classes directly, you + will now need to subclass them and provide trivial (e.g. empty) + implementations for the abstract methods. + + - :class:`scrapy.commands.ScrapyCommand` + + - :meth:`~scrapy.commands.ScrapyCommand.run` + + - :meth:`~scrapy.commands.ScrapyCommand.short_desc` + + - :class:`scrapy.exporters.BaseItemExporter` + + - :meth:`~scrapy.exporters.BaseItemExporter.export_item` + + - :class:`scrapy.extensions.feedexport.BlockingFeedStorage` + + - :meth:`~scrapy.extensions.feedexport.BlockingFeedStorage._store_in_thread` + + - :class:`scrapy.middleware.MiddlewareManager` + + - :meth:`~scrapy.middleware.MiddlewareManager._get_mwlist_from_settings` + + - :class:`scrapy.spidermiddlewares.referer.ReferrerPolicy` + + - :meth:`~scrapy.spidermiddlewares.referer.ReferrerPolicy.referrer` + +- :class:`scrapy.middleware.MiddlewareManager` no longer includes code for + handling ``open_spider()`` and ``close_spider()`` component methods. As + this code was only used for pipelines it was moved into + :class:`scrapy.pipelines.ItemPipelineManager`. This change should only + affect custom subclasses of :class:`~scrapy.middleware.MiddlewareManager`. + The following code was moved: + + - ``scrapy.middleware.MiddlewareManager.open_spider()`` + + - ``scrapy.middleware.MiddlewareManager.close_spider()`` + + - Code in ``scrapy.middleware.MiddlewareManager._add_middleware()`` that + processes ``open_spider()`` and ``close_spider()`` component methods. + +- :meth:`scrapy.downloadermiddlewares.robotstxt.RobotsTxtMiddleware.process_request` + now returns a coroutine, previously it returned a + :class:`~twisted.internet.defer.Deferred` object or ``None``. The + ``robot_parser()`` method was also changed to return a coroutine. This + change only impacts code that subclasses + :class:`~scrapy.downloadermiddlewares.robotstxt.RobotsTxtMiddleware` or + calls its methods directly. + +.. _release-2.13.4: + +Scrapy 2.13.4 (2025-11-17) +-------------------------- + +Security bug fixes +~~~~~~~~~~~~~~~~~~ + +- Improved protection against decompression bombs in + :class:`~scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware` + for responses compressed using the ``br`` and ``deflate`` methods: if a + single compressed chunk would be larger than the response size limit (see + :setting:`DOWNLOAD_MAXSIZE`) when decompressed, decompression is no longer + carried out. This is especially important for the ``br`` (Brotli) method + that can provide a very high compression ratio. Please, see the + `CVE-2025-6176`_ and `GHSA-2qfp-q593-8484`_ security advisories for more + information. + (:issue:`7134`) + + .. _CVE-2025-6176: https://nvd.nist.gov/vuln/detail/CVE-2025-6176 + .. _GHSA-2qfp-q593-8484: https://github.com/advisories/GHSA-2qfp-q593-8484 + +Modified requirements +~~~~~~~~~~~~~~~~~~~~~ + +- The minimum supported version of the optional ``brotli`` package is now + ``1.2.0``. + (:issue:`7134`) + +- The ``brotlicffi`` and ``brotlipy`` packages can no longer be used to + decompress Brotli-compressed responses. Please install the ``brotli`` + package instead. + (:issue:`7134`) + +Other changes +~~~~~~~~~~~~~ + +- Restricted the maximum supported Twisted version to ``25.5.0``, as Scrapy + currently uses some private APIs changed in later Twisted versions. + (:issue:`7142`) + +- Stopped setting the ``COVERAGE_CORE`` environment variable in tests, it + didn't have an effect but caused the ``coverage`` module to produce a + warning or an error. + (:issue:`7137`) + +- Removed the documentation build dependency on the deprecated + ``sphinx-hoverxref`` module. + (:issue:`6786`, :issue:`6922`) + +.. _release-2.13.3: + +Scrapy 2.13.3 (2025-07-02) +-------------------------- + +- Changed the values for :setting:`DOWNLOAD_DELAY` (from ``0`` to ``1``) and + :setting:`CONCURRENT_REQUESTS_PER_DOMAIN` (from ``8`` to ``1``) in the + default project template. + (:issue:`6597`, :issue:`6918`, :issue:`6923`) + +- Improved :class:`scrapy.core.engine.ExecutionEngine` logic related to + initialization and exception handling, fixing several cases where the + spider would crash, hang or log an unhandled exception. + (:issue:`6783`, :issue:`6784`, :issue:`6900`, :issue:`6908`, :issue:`6910`, + :issue:`6911`) + +- Fixed a Windows issue with :ref:`feed exports ` using + :class:`scrapy.extensions.feedexport.FileFeedStorage` that caused the file + to be created on the wrong drive. + (:issue:`6894`, :issue:`6897`) + +- Allowed running tests with Twisted 25.5.0+ again. Pytest 8.4.1+ is now + required for running tests in non-pinned envs as support for the new + Twisted version was added in that version. + (:issue:`6893`) + +- Fixed running tests with lxml 6.0.0+. + (:issue:`6919`) + +- Added a deprecation notice for + ``scrapy.spidermiddlewares.offsite.OffsiteMiddleware`` to :ref:`the Scrapy + 2.11.2 release notes `. + (:issue:`6926`) + +- Updated :ref:`contribution docs ` to refer to ruff_ + instead of black_. + (:issue:`6903`) + +- Added ``.venv/`` and ``.vscode/`` to ``.gitignore``. + (:issue:`6901`, :issue:`6907`) + .. _release-2.13.2: @@ -340,7 +490,7 @@ Deprecations (:issue:`6708`, :issue:`6714`) - ``scrapy.utils.versions.scrapy_components_versions()`` is deprecated, use - :func:`scrapy.utils.versions.get_versions()` instead. + :func:`scrapy.utils.versions.get_versions` instead. (:issue:`6582`) - ``BaseDupeFilter.log()`` is deprecated. It does nothing and shouldn't be @@ -1233,6 +1383,17 @@ Security bug fixes .. _defusedxml: https://github.com/tiran/defusedxml +Deprecations +~~~~~~~~~~~~ + +- ``scrapy.spidermiddlewares.offsite.OffsiteMiddleware`` (a spider + middleware) is now deprecated and not enabled by default. The new + downloader middleware with the same functionality, + :class:`scrapy.downloadermiddlewares.offsite.OffsiteMiddleware`, is enabled + instead. + (:issue:`2241`, :issue:`6358`) + + Bug fixes ~~~~~~~~~ @@ -1786,7 +1947,7 @@ Bug fixes (:issue:`5914`, :issue:`5917`) - Fixed an error breaking user handling of send failures in - :meth:`scrapy.mail.MailSender.send()`. (:issue:`1611`, :issue:`5880`) + :meth:`scrapy.mail.MailSender.send`. (:issue:`1611`, :issue:`5880`) Documentation ~~~~~~~~~~~~~ @@ -5336,7 +5497,7 @@ Docs - Added missing bullet point for the ``AUTOTHROTTLE_TARGET_CONCURRENCY`` setting. (:issue:`2756`) - Update Contributing docs, document new support channels - (:issue:`2762`, issue:`3038`) + (:issue:`2762`, :issue:`3038`) - Include references to Scrapy subreddit in the docs - Fix broken links; use ``https://`` for external links (:issue:`2978`, :issue:`2982`, :issue:`2958`) diff --git a/docs/requirements.txt b/docs/requirements.txt index 103fb08d6..81a3119e4 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,5 +1,6 @@ +pydantic==2.12.3 +scrapy-spider-metadata==0.2.0 sphinx==8.1.3 -sphinx-hoverxref==1.4.2 sphinx-notfound-page==1.0.4 sphinx-rtd-theme==3.0.2 sphinx-rtd-dark-mode==1.3.0 diff --git a/docs/topics/addons.rst b/docs/topics/addons.rst index 815501e66..dba14e749 100644 --- a/docs/topics/addons.rst +++ b/docs/topics/addons.rst @@ -88,7 +88,7 @@ recommend that such custom components should be written in the following way: 1. The custom component (e.g. ``MyDownloadHandler``) shouldn't inherit from the default Scrapy one (e.g. - ``scrapy.core.downloader.handlers.http.HTTPDownloadHandler``), but instead + ``scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler``), but instead be able to load the class of the fallback component from a special setting (e.g. ``MY_FALLBACK_DOWNLOAD_HANDLER``), create an instance of it and use it. @@ -166,7 +166,6 @@ Use a fallback component: .. code-block:: python - from scrapy.core.downloader.handlers.http import HTTPDownloadHandler from scrapy.utils.misc import build_from_crawler diff --git a/docs/topics/api.rst b/docs/topics/api.rst index d90eb0bad..19082d9d7 100644 --- a/docs/topics/api.rst +++ b/docs/topics/api.rst @@ -280,13 +280,13 @@ class (which they all inherit from). The following methods are not part of the stats collection api but instead used when implementing custom stats collectors: - .. method:: open_spider(spider) + .. method:: open_spider() - Open the given spider for stats collection. + Open the spider for stats collection. - .. method:: close_spider(spider) + .. method:: close_spider() - Close the given spider. After this is called, no more specific stats + Close the spider. After this is called, no more specific stats can be accessed or collected. Engine API diff --git a/docs/topics/asyncio.rst b/docs/topics/asyncio.rst index ad5c71fbf..ca5bc9a5c 100644 --- a/docs/topics/asyncio.rst +++ b/docs/topics/asyncio.rst @@ -49,6 +49,7 @@ You can usually fix the issue by moving those offending module-level Twisted imports to the method or function definitions where they are used. For example, if you have something like: +.. skip: next .. code-block:: python from twisted.internet import reactor diff --git a/docs/topics/autothrottle.rst b/docs/topics/autothrottle.rst index 5bd72fa15..d0321c906 100644 --- a/docs/topics/autothrottle.rst +++ b/docs/topics/autothrottle.rst @@ -37,8 +37,7 @@ processed in parallel. Instead of adjusting the delays one can just set a small fixed download delay and impose hard limits on concurrency using -:setting:`CONCURRENT_REQUESTS_PER_DOMAIN` or -:setting:`CONCURRENT_REQUESTS_PER_IP` options. It will provide a similar +:setting:`CONCURRENT_REQUESTS_PER_DOMAIN`. It will provide a similar effect, but there are some important differences: * because the download delay is small there will be occasional bursts @@ -71,7 +70,6 @@ AutoThrottle algorithm adjusts download delays based on the following rules: .. note:: The AutoThrottle extension honours the standard Scrapy settings for concurrency and delay. This means that it will respect :setting:`CONCURRENT_REQUESTS_PER_DOMAIN` and - :setting:`CONCURRENT_REQUESTS_PER_IP` options and never set a download delay lower than :setting:`DOWNLOAD_DELAY`. .. _download-latency: @@ -123,7 +121,6 @@ The settings used to control the AutoThrottle extension are: * :setting:`AUTOTHROTTLE_TARGET_CONCURRENCY` * :setting:`AUTOTHROTTLE_DEBUG` * :setting:`CONCURRENT_REQUESTS_PER_DOMAIN` -* :setting:`CONCURRENT_REQUESTS_PER_IP` * :setting:`DOWNLOAD_DELAY` For more information see :ref:`autothrottle-algorithm`. @@ -171,12 +168,10 @@ a higher value (e.g. ``2.0``) to increase the throughput and the load on remote servers. A lower ``AUTOTHROTTLE_TARGET_CONCURRENCY`` value (e.g. ``0.5``) makes the crawler more conservative and polite. -Note that :setting:`CONCURRENT_REQUESTS_PER_DOMAIN` -and :setting:`CONCURRENT_REQUESTS_PER_IP` options are still respected +Note that :setting:`CONCURRENT_REQUESTS_PER_DOMAIN` is still respected when AutoThrottle extension is enabled. This means that if ``AUTOTHROTTLE_TARGET_CONCURRENCY`` is set to a value higher than -:setting:`CONCURRENT_REQUESTS_PER_DOMAIN` or -:setting:`CONCURRENT_REQUESTS_PER_IP`, the crawler won't reach this number +:setting:`CONCURRENT_REQUESTS_PER_DOMAIN`, the crawler won't reach this number of concurrent requests. At every given time point Scrapy can be sending more or less concurrent diff --git a/docs/topics/benchmarking.rst b/docs/topics/benchmarking.rst index b704e54ed..e8ddec00c 100644 --- a/docs/topics/benchmarking.rst +++ b/docs/topics/benchmarking.rst @@ -83,4 +83,4 @@ and how well it's written. Use scrapy-bench_ for more complex benchmarking. -.. _scrapy-bench: https://github.com/scrapy/scrapy-bench \ No newline at end of file +.. _scrapy-bench: https://github.com/scrapy/scrapy-bench diff --git a/docs/topics/broad-crawls.rst b/docs/topics/broad-crawls.rst index 248e38b61..b4d058754 100644 --- a/docs/topics/broad-crawls.rst +++ b/docs/topics/broad-crawls.rst @@ -61,12 +61,7 @@ Increase concurrency Concurrency is the number of requests that are processed in parallel. There is a global limit (:setting:`CONCURRENT_REQUESTS`) and an additional limit that -can be set either per domain (:setting:`CONCURRENT_REQUESTS_PER_DOMAIN`) or per -IP (:setting:`CONCURRENT_REQUESTS_PER_IP`). - -.. note:: The scheduler priority queue :ref:`recommended for broad crawls - ` does not support - :setting:`CONCURRENT_REQUESTS_PER_IP`. +can be set per domain (:setting:`CONCURRENT_REQUESTS_PER_DOMAIN`). The default global concurrency limit in Scrapy is not suitable for crawling many different domains in parallel, so you will want to increase it. How much @@ -143,7 +138,7 @@ To disable cookies use: Disable retries =============== -Retrying failed HTTP requests can slow down the crawls substantially, specially +Retrying failed HTTP requests can slow down the crawls substantially, especially when sites causes are very slow (or fail) to respond, thus causing a timeout error which gets retried many times, unnecessarily, preventing crawler capacity to be reused for other domains. diff --git a/docs/topics/coroutines.rst b/docs/topics/coroutines.rst index 2c0df5e0f..fdd3dcd87 100644 --- a/docs/topics/coroutines.rst +++ b/docs/topics/coroutines.rst @@ -191,7 +191,7 @@ shorter and cleaner: adapter["field"] = data return item - def process_item(self, item, spider): + def process_item(self, item): adapter = ItemAdapter(item) dfd = db.get_some_data(adapter["id"]) dfd.addCallback(self._update_item, item) @@ -205,7 +205,7 @@ becomes: class DbPipeline: - async def process_item(self, item, spider): + async def process_item(self, item): adapter = ItemAdapter(item) adapter["field"] = await db.get_some_data(adapter["id"]) return item @@ -266,7 +266,6 @@ within a spider callback: .. code-block:: python from scrapy import Spider, Request - from scrapy.utils.defer import maybe_deferred_to_future class SingleRequestSpider(Spider): @@ -275,8 +274,9 @@ within a spider callback: async def parse(self, response, **kwargs): additional_request = Request("https://example.org/price") - deferred = self.crawler.engine.download(additional_request) - additional_response = await maybe_deferred_to_future(deferred) + additional_response = await self.crawler.engine.download_async( + additional_request + ) yield { "h1": response.css("h1").get(), "price": additional_response.css("#price").get(), @@ -286,9 +286,9 @@ You can also send multiple requests in parallel: .. code-block:: python + import asyncio + from scrapy import Spider, Request - from scrapy.utils.defer import maybe_deferred_to_future - from twisted.internet.defer import DeferredList class MultipleRequestsSpider(Spider): @@ -300,11 +300,11 @@ You can also send multiple requests in parallel: Request("https://example.com/price"), Request("https://example.com/color"), ] - deferreds = [] + tasks = [] for r in additional_requests: - deferred = self.crawler.engine.download(r) - deferreds.append(deferred) - responses = await maybe_deferred_to_future(DeferredList(deferreds)) + task = self.crawler.engine.download_async(r) + tasks.append(task) + responses = await asyncio.gather(*tasks) yield { "h1": response.css("h1::text").get(), "price": responses[0][1].css(".price::text").get(), @@ -421,12 +421,12 @@ For example: .. code-block:: python class UniversalSpiderMiddleware: - def process_spider_output(self, response, result, spider): + def process_spider_output(self, response, result): for r in result: # ... do something with r yield r - async def process_spider_output_async(self, response, result, spider): + async def process_spider_output_async(self, response, result): async for r in result: # ... do something with r yield r diff --git a/docs/topics/developer-tools.rst b/docs/topics/developer-tools.rst index 89a4d32d8..05dffcdda 100644 --- a/docs/topics/developer-tools.rst +++ b/docs/topics/developer-tools.rst @@ -280,7 +280,7 @@ In more complex websites, it could be difficult to easily reproduce the requests, as we could need to add ``headers`` or ``cookies`` to make it work. In those cases you can export the requests in `cURL `_ format, by right-clicking on each of them in the network tool and using the -:meth:`~scrapy.Request.from_curl()` method to generate an equivalent +:meth:`~scrapy.Request.from_curl` method to generate an equivalent request: .. code-block:: python @@ -317,4 +317,3 @@ to identifying the correct request and replicating it in your spider. .. _quotes.toscrape.com/scroll: https://quotes.toscrape.com/scroll .. _quotes.toscrape.com/api/quotes?page=10: https://quotes.toscrape.com/api/quotes?page=10 .. _has-class-extension: https://parsel.readthedocs.io/en/latest/usage.html#other-xpath-extensions - diff --git a/docs/topics/downloader-middleware.rst b/docs/topics/downloader-middleware.rst index 60b6aab78..6f36acc68 100644 --- a/docs/topics/downloader-middleware.rst +++ b/docs/topics/downloader-middleware.rst @@ -70,7 +70,7 @@ defines one or more of these methods: .. note:: Any of the downloader middleware methods may also return a deferred. - .. method:: process_request(request, spider) + .. method:: process_request(request) This method is called for each request that goes through the download middleware. @@ -102,10 +102,7 @@ defines one or more of these methods: :param request: the request being processed :type request: :class:`~scrapy.Request` object - :param spider: the spider for which this request is intended - :type spider: :class:`~scrapy.Spider` object - - .. method:: process_response(request, response, spider) + .. method:: process_response(request, response) :meth:`process_response` should either: return a :class:`~scrapy.http.Response` object, return a :class:`~scrapy.Request` object or @@ -129,10 +126,7 @@ defines one or more of these methods: :param response: the response being processed :type response: :class:`~scrapy.http.Response` object - :param spider: the spider for which this response is intended - :type spider: :class:`~scrapy.Spider` object - - .. method:: process_exception(request, exception, spider) + .. method:: process_exception(request, exception) Scrapy calls :meth:`process_exception` when a download handler or a :meth:`process_request` (from a downloader middleware) raises an @@ -160,9 +154,6 @@ defines one or more of these methods: :param exception: the raised exception :type exception: an ``Exception`` object - :param spider: the spider for which this request is intended - :type spider: :class:`~scrapy.Spider` object - .. _topics-downloader-middleware-ref: Built-in downloader middleware reference diff --git a/docs/topics/dynamic-content.rst b/docs/topics/dynamic-content.rst index 6c57a88f1..18b3ce244 100644 --- a/docs/topics/dynamic-content.rst +++ b/docs/topics/dynamic-content.rst @@ -86,7 +86,7 @@ method and URL. However, you may also need to reproduce the body, headers and form parameters (see :class:`~scrapy.FormRequest`) of that request. As all major browsers allow to export the requests in curl_ format, Scrapy -incorporates the method :meth:`~scrapy.Request.from_curl()` to generate an equivalent +incorporates the method :meth:`~scrapy.Request.from_curl` to generate an equivalent :class:`~scrapy.Request` from a cURL command. To get more information visit :ref:`request from curl ` inside the network tool section. diff --git a/docs/topics/exporters.rst b/docs/topics/exporters.rst index 5c078568b..d3186ef5c 100644 --- a/docs/topics/exporters.rst +++ b/docs/topics/exporters.rst @@ -67,7 +67,7 @@ value of one of their fields: self.year_to_exporter[year] = (exporter, xml_file) return self.year_to_exporter[year][0] - def process_item(self, item, spider): + def process_item(self, item): exporter = self._exporter_for_item(item) exporter.export_item(item) return item @@ -116,10 +116,10 @@ Example: 2. Overriding the serialize_field() method ------------------------------------------ -You can also override the :meth:`~BaseItemExporter.serialize_field()` method to +You can also override the :meth:`~BaseItemExporter.serialize_field` method to customize how your field value will be exported. -Make sure you call the base class :meth:`~BaseItemExporter.serialize_field()` method +Make sure you call the base class :meth:`~BaseItemExporter.serialize_field` method after your custom code. Example: diff --git a/docs/topics/extensions.rst b/docs/topics/extensions.rst index e1e3dd6b4..0fbd22a27 100644 --- a/docs/topics/extensions.rst +++ b/docs/topics/extensions.rst @@ -138,6 +138,14 @@ enabled (see :ref:`topics-stats`). .. _topics-extensions-ref-telnetconsole: +Log Count extension +~~~~~~~~~~~~~~~~~~~ + +.. module:: scrapy.extensions.logcount + :synopsis: Basic stats logging + +.. autoclass:: LogCount + Telnet console extension ~~~~~~~~~~~~~~~~~~~~~~~~ @@ -259,7 +267,7 @@ CLOSESPIDER_TIMEOUT Default: ``0`` An integer which specifies a number of seconds. If the spider remains open for -more than that number of second, it will be automatically closed with the +more than that number of seconds, it will be automatically closed with the reason ``closespider_timeout``. If zero (or non set), spiders won't be closed by timeout. diff --git a/docs/topics/item-pipeline.rst b/docs/topics/item-pipeline.rst index dc27ce6ca..e67cf06c8 100644 --- a/docs/topics/item-pipeline.rst +++ b/docs/topics/item-pipeline.rst @@ -26,7 +26,7 @@ Writing your own item pipeline Each item pipeline is a :ref:`component ` that must implement the following method: -.. method:: process_item(self, item, spider) +.. method:: process_item(self, item) This method is called for every item pipeline component. @@ -42,25 +42,16 @@ implement the following method: :param item: the scraped item :type item: :ref:`item object ` - :param spider: the spider which scraped the item - :type spider: :class:`~scrapy.Spider` object - Additionally, they may also implement the following methods: -.. method:: open_spider(self, spider) +.. method:: open_spider(self) This method is called when the spider is opened. - :param spider: the spider which was opened - :type spider: :class:`~scrapy.Spider` object - -.. method:: close_spider(self, spider) +.. method:: close_spider(self) This method is called when the spider is closed. - :param spider: the spider which was closed - :type spider: :class:`~scrapy.Spider` object - Item pipeline example ===================== @@ -82,7 +73,7 @@ contain a price: class PricePipeline: vat_factor = 1.15 - def process_item(self, item, spider): + def process_item(self, item): adapter = ItemAdapter(item) if adapter.get("price"): if adapter.get("price_excludes_vat"): @@ -107,13 +98,13 @@ format: class JsonWriterPipeline: - def open_spider(self, spider): + def open_spider(self): self.file = open("items.jsonl", "w") - def close_spider(self, spider): + def close_spider(self): self.file.close() - def process_item(self, item, spider): + def process_item(self, item): line = json.dumps(ItemAdapter(item).asdict()) + "\n" self.file.write(line) return item @@ -153,14 +144,14 @@ The main point of this example is to show how to :ref:`get the crawler mongo_db=crawler.settings.get("MONGO_DATABASE", "items"), ) - def open_spider(self, spider): + def open_spider(self): self.client = pymongo.MongoClient(self.mongo_uri) self.db = self.client[self.mongo_db] - def close_spider(self, spider): + def close_spider(self): self.client.close() - def process_item(self, item, spider): + def process_item(self, item): self.db[self.collection_name].insert_one(ItemAdapter(item).asdict()) return item @@ -190,7 +181,6 @@ item. import scrapy from itemadapter import ItemAdapter from scrapy.http.request import NO_CALLBACK - from scrapy.utils.defer import maybe_deferred_to_future class ScreenshotPipeline: @@ -199,14 +189,19 @@ item. SPLASH_URL = "http://localhost:8050/render.png?url={}" - async def process_item(self, item, spider): + def __init__(crawler): + self.crawler = crawler + + @classmethod + def from_crawler(cls, crawler): + return cls(crawler) + + async def process_item(self, item): adapter = ItemAdapter(item) encoded_item_url = quote(adapter["url"]) screenshot_url = self.SPLASH_URL.format(encoded_item_url) request = scrapy.Request(screenshot_url, callback=NO_CALLBACK) - response = await maybe_deferred_to_future( - spider.crawler.engine.download(request) - ) + response = await self.crawler.engine.download_async(request) if response.status != 200: # Error happened, return item. @@ -241,7 +236,7 @@ returns multiples items with the same id: def __init__(self): self.ids_seen = set() - def process_item(self, item, spider): + def process_item(self, item): adapter = ItemAdapter(item) if adapter["id"] in self.ids_seen: raise DropItem(f"Item ID already seen: {adapter['id']}") diff --git a/docs/topics/leaks.rst b/docs/topics/leaks.rst index bbe1f3dd4..e61f33aed 100644 --- a/docs/topics/leaks.rst +++ b/docs/topics/leaks.rst @@ -162,7 +162,7 @@ Too many spiders? ----------------- If your project has too many spiders executed in parallel, -the output of :func:`prefs()` can be difficult to read. +the output of :func:`prefs` can be difficult to read. For this reason, that function has a ``ignore`` argument which can be used to ignore a particular class (and all its subclasses). For example, this won't show any live references to spiders: diff --git a/docs/topics/media-pipeline.rst b/docs/topics/media-pipeline.rst index 01da53342..fa17dfda6 100644 --- a/docs/topics/media-pipeline.rst +++ b/docs/topics/media-pipeline.rst @@ -70,7 +70,7 @@ The advantage of using the :class:`ImagesPipeline` for image files is that you can configure some extra functions like generating thumbnails and filtering the images based on their size. -The Images Pipeline requires Pillow_ 8.0.0 or greater. It is used for +The Images Pipeline requires Pillow_ 8.3.2 or greater. It is used for thumbnailing and normalizing images to JPEG/RGB format. .. _Pillow: https://github.com/python-pillow/Pillow @@ -238,7 +238,7 @@ Amazon S3 storage .. setting:: FILES_STORE_S3_ACL .. setting:: IMAGES_STORE_S3_ACL -If botocore_ >= 1.4.87 is installed, :setting:`FILES_STORE` and +If botocore_ >= 1.13.45 is installed, :setting:`FILES_STORE` and :setting:`IMAGES_STORE` can represent an Amazon S3 bucket. Scrapy will automatically upload the files to the bucket. diff --git a/docs/topics/practices.rst b/docs/topics/practices.rst index 56177ba4e..79d5bcce9 100644 --- a/docs/topics/practices.rst +++ b/docs/topics/practices.rst @@ -281,7 +281,7 @@ finishes before starting the next one: Distributed crawls ================== -Scrapy doesn't provide any built-in facility for running crawls in a distribute +Scrapy doesn't provide any built-in facility for running crawls in a distributed (multi-server) manner. However, there are some ways to distribute crawls, which vary depending on how you plan to distribute them. @@ -289,10 +289,10 @@ If you have many spiders, the obvious way to distribute the load is to setup many Scrapyd instances and distribute spider runs among those. If you instead want to run a single (big) spider through many machines, what -you usually do is partition the urls to crawl and send them to each separate +you usually do is partition the URLs to crawl and send them to each separate spider. Here is a concrete example: -First, you prepare the list of urls to crawl and put them into separate +First, you prepare the list of URLs to crawl and put them into separate files/urls:: http://somedomain.com/urls-to-crawl/spider1/part1.list @@ -319,7 +319,7 @@ consider contacting `commercial support`_ if in doubt. Here are some tips to keep in mind when dealing with these kinds of sites: -* rotate your user agent from a pool of well-known ones from browsers (google +* rotate your user agent from a pool of well-known ones from browsers (Google around to get a list of them) * disable cookies (see :setting:`COOKIES_ENABLED`) as some sites may use cookies to spot bot behaviour diff --git a/docs/topics/request-response.rst b/docs/topics/request-response.rst index 6ca0973d8..8a907e377 100644 --- a/docs/topics/request-response.rst +++ b/docs/topics/request-response.rst @@ -873,7 +873,7 @@ It is usual for web sites to provide pre-populated form fields through ```` elements, such as session related data or authentication tokens (for login pages). When scraping, you'll want these fields to be automatically pre-populated and only override a couple of them, such as the -user name and password. You can use the :meth:`.FormRequest.from_response()` +user name and password. You can use the :meth:`.FormRequest.from_response` method for this job. Here's an example spider which uses it: .. code-block:: python diff --git a/docs/topics/settings.rst b/docs/topics/settings.rst index 2a1be5f88..a92070918 100644 --- a/docs/topics/settings.rst +++ b/docs/topics/settings.rst @@ -517,7 +517,7 @@ performed by the Scrapy downloader. CONCURRENT_REQUESTS_PER_DOMAIN ------------------------------ -Default: ``8`` +Default: ``1`` (:ref:`fallback `: ``8``) The maximum number of concurrent (i.e. simultaneous) requests that will be performed to any single domain. @@ -526,23 +526,6 @@ See also: :ref:`topics-autothrottle` and its :setting:`AUTOTHROTTLE_TARGET_CONCURRENCY` option. -.. setting:: CONCURRENT_REQUESTS_PER_IP - -CONCURRENT_REQUESTS_PER_IP --------------------------- - -Default: ``0`` - -The maximum number of concurrent (i.e. simultaneous) requests that will be -performed to any single IP. If non-zero, the -:setting:`CONCURRENT_REQUESTS_PER_DOMAIN` setting is ignored, and this one is -used instead. In other words, concurrency limits will be applied per IP, not -per domain. - -This setting also affects :setting:`DOWNLOAD_DELAY` and -:ref:`topics-autothrottle`: if :setting:`CONCURRENT_REQUESTS_PER_IP` -is non-zero, download delay is enforced per IP, not per domain. - .. setting:: DEFAULT_DROPITEM_LOG_LEVEL DEFAULT_DROPITEM_LOG_LEVEL @@ -571,7 +554,7 @@ When writing an item pipeline, you can force a different log level by setting class MyPipeline: - def process_item(self, item, spider): + def process_item(self, item): if not item.get("price"): raise DropItem("Missing price data", log_level="INFO") return item @@ -728,7 +711,7 @@ connections (for ``HTTP10DownloadHandler``). so you can safely ignore this setting, unless you really want to use HTTP/1.0 and override :setting:`DOWNLOAD_HANDLERS` for ``http(s)`` scheme accordingly, - i.e. to ``'scrapy.core.downloader.handlers.http.HTTP10DownloadHandler'``. + i.e. to ``'scrapy.core.downloader.handlers.http10.HTTP10DownloadHandler'``. .. setting:: DOWNLOADER_CLIENTCONTEXTFACTORY @@ -869,7 +852,7 @@ Whether to enable downloader stats collection. DOWNLOAD_DELAY -------------- -Default: ``0`` +Default: ``1`` (:ref:`fallback `: ``0``) Minimum seconds to wait between 2 consecutive requests to the same domain. @@ -884,9 +867,6 @@ every 10 seconds:: This setting is also affected by the :setting:`RANDOMIZE_DOWNLOAD_DELAY` setting, which is enabled by default. -When :setting:`CONCURRENT_REQUESTS_PER_IP` is non-zero, delays are enforced -per IP address instead of per domain. - Note that :setting:`DOWNLOAD_DELAY` can lower the effective per-domain concurrency below :setting:`CONCURRENT_REQUESTS_PER_DOMAIN`. If the response time of a domain is lower than :setting:`DOWNLOAD_DELAY`, the effective @@ -929,8 +909,8 @@ Default: { "data": "scrapy.core.downloader.handlers.datauri.DataURIDownloadHandler", "file": "scrapy.core.downloader.handlers.file.FileDownloadHandler", - "http": "scrapy.core.downloader.handlers.http.HTTPDownloadHandler", - "https": "scrapy.core.downloader.handlers.http.HTTPDownloadHandler", + "http": "scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler", + "https": "scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler", "s3": "scrapy.core.downloader.handlers.s3.S3DownloadHandler", "ftp": "scrapy.core.downloader.handlers.ftp.FTPDownloadHandler", } @@ -1151,9 +1131,9 @@ interface:: class MyDupeFilter: @classmethod - def from_settings(cls, settings): + def from_crawler(cls, crawler): """Returns an instance of this duplicate request filtering class - based on the current crawl settings.""" + based on the current Crawler instance.""" return cls() def request_seen(self, request): @@ -1765,8 +1745,7 @@ Type of priority queue used by the scheduler. Another available type is ``scrapy.pqueues.DownloaderAwarePriorityQueue``. ``scrapy.pqueues.DownloaderAwarePriorityQueue`` works better than ``scrapy.pqueues.ScrapyPriorityQueue`` when you crawl many different -domains in parallel. But currently ``scrapy.pqueues.DownloaderAwarePriorityQueue`` -does not work together with :setting:`CONCURRENT_REQUESTS_PER_IP`. +domains in parallel. .. setting:: SCHEDULER_START_DISK_QUEUE @@ -2024,6 +2003,7 @@ reactor is installed. In order to use the reactor installed by Scrapy: +.. skip: next .. code-block:: python import scrapy diff --git a/docs/topics/shell.rst b/docs/topics/shell.rst index 85a08cebd..8ae8ff512 100644 --- a/docs/topics/shell.rst +++ b/docs/topics/shell.rst @@ -24,7 +24,7 @@ If you have `IPython`_ installed, the Scrapy shell will use it (instead of the standard Python console). The `IPython`_ console is much more powerful and provides smart auto-completion and colorized output, among other things. -We highly recommend you install `IPython`_, specially if you're working on +We highly recommend you install `IPython`_, especially if you're working on Unix systems (where `IPython`_ excels). See the `IPython installation guide`_ for more info. diff --git a/docs/topics/spider-middleware.rst b/docs/topics/spider-middleware.rst index 67178b8fd..0f81e409e 100644 --- a/docs/topics/spider-middleware.rst +++ b/docs/topics/spider-middleware.rst @@ -94,7 +94,7 @@ one or more of these methods: def process_start_requests(self, start, spider): yield from start - .. method:: process_spider_input(response, spider) + .. method:: process_spider_input(response) This method is called for each response that goes through the spider middleware and into the spider, for processing. @@ -116,11 +116,7 @@ one or more of these methods: :param response: the response being processed :type response: :class:`~scrapy.http.Response` object - :param spider: the spider for which this response is intended - :type spider: :class:`~scrapy.Spider` object - - - .. method:: process_spider_output(response, result, spider) + .. method:: process_spider_output(response, result) This method is called with the results returned from the Spider, after it has processed the response. @@ -149,10 +145,7 @@ one or more of these methods: :type result: an iterable of :class:`~scrapy.Request` objects and :ref:`item objects ` - :param spider: the spider whose result is being processed - :type spider: :class:`~scrapy.Spider` object - - .. method:: process_spider_output_async(response, result, spider) + .. method:: process_spider_output_async(response, result) :async: .. versionadded:: 2.7 @@ -161,7 +154,7 @@ one or more of these methods: which will be called instead of :meth:`process_spider_output` if ``result`` is an :term:`asynchronous iterable`. - .. method:: process_spider_exception(response, exception, spider) + .. method:: process_spider_exception(response, exception) This method is called when a spider or :meth:`process_spider_output` method (from a previous spider middleware) raises an exception. @@ -186,8 +179,6 @@ one or more of these methods: :param exception: the exception raised :type exception: :exc:`Exception` object - :param spider: the spider which raised the exception - :type spider: :class:`~scrapy.Spider` object Base class for custom spider middlewares ---------------------------------------- @@ -354,7 +345,7 @@ Default: ``'scrapy.spidermiddlewares.referer.DefaultReferrerPolicy'`` Acceptable values for REFERRER_POLICY ************************************* -- either a path to a ``scrapy.spidermiddlewares.referer.ReferrerPolicy`` +- either a path to a :class:`scrapy.spidermiddlewares.referer.ReferrerPolicy` subclass — a custom policy or one of the built-in ones (see classes below), - or one or more comma-separated standard W3C-defined string values, - or the special ``"scrapy-default"``. @@ -373,6 +364,8 @@ String value Class name (as a string) `"unsafe-url"`_ :class:`scrapy.spidermiddlewares.referer.UnsafeUrlPolicy` ======================================= ======================================================================== +.. autoclass:: ReferrerPolicy + .. autoclass:: DefaultReferrerPolicy .. warning:: Scrapy's default referrer policy — just like `"no-referrer-when-downgrade"`_, diff --git a/docs/topics/spiders.rst b/docs/topics/spiders.rst index 8240d5d4b..0a83b248e 100644 --- a/docs/topics/spiders.rst +++ b/docs/topics/spiders.rst @@ -364,6 +364,52 @@ used by :class:`~scrapy.downloadermiddlewares.useragent.UserAgentMiddleware`:: Spider arguments can also be passed through the Scrapyd ``schedule.json`` API. See `Scrapyd documentation`_. +scrapy-spider-metadata parameters +--------------------------------- + +Another alternative to pass spider arguments is the library `scrapy-spider-metadata`_. + +This allows for Scrapy spiders to define, validate, document and pre-process +their arguments as Pydantic models. + +The example shows how to define typed parameters where a string argument +is automatically converted to an integer: + +.. code-block:: python + + import scrapy + from pydantic import BaseModel + from scrapy_spider_metadata import Args + + + class MyParams(BaseModel): + pages: int + + + class BookSpider(Args[MyParams], scrapy.Spider): + name = "bookspider" + start_urls = ["http://books.toscrape.com/catalogue"] + + async def start(self): + for start_url in self.start_urls: + for index in range(1, self.args.pages + 1): + yield scrapy.Request(f"{start_url}/page-{index}.html") + + def parse(self, response): + book_links = response.css("article.product_pod h3 a::attr(href)").getall() + for book_link in book_links: + yield response.follow(book_link, self.parse_book) + + def parse_book(self, response): + yield { + "title": response.css("h1::text").get(), + "price": response.css("p.price_color::text").get(), + } + +This spider can be called from the command line:: + + scrapy crawl bookspider -a pages=2 + .. _start-requests: Start requests @@ -628,7 +674,7 @@ XMLFeedSpider This method is called for the nodes matching the provided tag name (``itertag``). Receives the response and an :class:`~scrapy.Selector` for each node. Overriding this - method is mandatory. Otherwise, you spider won't work. This method + method is mandatory. Otherwise, your spider won't work. This method must return an :ref:`item object `, a :class:`~scrapy.Request` object, or an iterable containing any of them. @@ -938,6 +984,7 @@ Combine SitemapSpider with other sources of urls: def parse_other(self, response): pass # ... scrape other here ... +.. _scrapy-spider-metadata: https://scrapy-spider-metadata.readthedocs.io/en/latest/params.html .. _Sitemaps: https://www.sitemaps.org/index.html .. _Sitemap index files: https://www.sitemaps.org/protocol.html#index .. _robots.txt: https://www.robotstxt.org/ diff --git a/docs/topics/stats.rst b/docs/topics/stats.rst index e34999b58..ac714eacb 100644 --- a/docs/topics/stats.rst +++ b/docs/topics/stats.rst @@ -121,4 +121,3 @@ DummyStatsCollector setting, to disable stats collect in order to improve performance. However, the performance penalty of stats collection is usually marginal compared to other Scrapy workload like parsing pages. - diff --git a/docs/versioning.rst b/docs/versioning.rst index 9d02757b0..aab6af865 100644 --- a/docs/versioning.rst +++ b/docs/versioning.rst @@ -66,4 +66,3 @@ the :ref:`release notes `. .. _odd-numbered versions for development releases: https://en.wikipedia.org/wiki/Software_versioning#Odd-numbered_versions_for_development_releases - diff --git a/pyproject.toml b/pyproject.toml index bc809a7b1..81351bab7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,13 +7,14 @@ name = "Scrapy" dynamic = ["version"] description = "A high-level Web Crawling and Web Scraping framework" dependencies = [ - "Twisted>=21.7.0", + # Twisted pinned until Scrapy is updated for its internal TLS API changes + "Twisted>=21.7.0,<=25.5.0", "cryptography>=37.0.0", "cssselect>=0.9.1", "defusedxml>=0.7.1", "itemadapter>=0.1.0", "itemloaders>=1.0.1", - "lxml>=4.6.0", + "lxml>=4.6.4", "packaging", "parsel>=1.5.0", "protego>=0.1.15", @@ -35,7 +36,6 @@ classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -49,7 +49,7 @@ classifiers = [ license = "BSD-3-Clause" license-files = ["LICENSE", "AUTHORS"] readme = "README.rst" -requires-python = ">=3.9" +requires-python = ">=3.10" authors = [{ name = "Scrapy developers", email = "pablo@pablohoffman.com" }] maintainers = [{ name = "Pablo Hoffman", email = "pablo@pablohoffman.com" }] @@ -106,16 +106,12 @@ follow_imports = "skip" module = "scrapy.settings.default_settings" ignore_errors = true -[[tool.mypy.overrides]] -module = "itemadapter" -implicit_reexport = true - [[tool.mypy.overrides]] module = "twisted" implicit_reexport = true [tool.bumpversion] -current_version = "2.13.2" +current_version = "2.13.4" commit = true tag = true tag_name = "{new_version}" @@ -139,6 +135,9 @@ branch = true include = ["scrapy/*"] omit = ["tests/*"] disable_warnings = ["include-ignored"] +patch = [ + "subprocess", +] [tool.coverage.paths] source = [ @@ -217,12 +216,14 @@ disable = [ "keyword-arg-before-vararg", "pointless-statement", "raise-missing-from", - "unbalanced-tuple-unpacking", "unnecessary-dunder-call", "used-before-assignment", ] [tool.pytest.ini_options] +addopts = [ + "--reactor=asyncio", +] xfail_strict = true python_files = ["test_*.py", "test_*/__init__.py"] markers = [ @@ -231,6 +232,7 @@ markers = [ "requires_uvloop: marks tests as only enabled when uvloop is known to be working", "requires_botocore: marks tests that need botocore (but not boto3)", "requires_boto3: marks tests that need botocore and boto3", + "requires_mitmproxy: marks tests that need mitmproxy", ] filterwarnings = [ "ignore::DeprecationWarning:twisted.web.static" @@ -238,10 +240,16 @@ filterwarnings = [ [tool.ruff.lint] extend-select = [ + # flake8-builtins + "A", + # flake8-async + "ASYNC", # flake8-bugbear "B", # flake8-comprehensions "C4", + # flake8-commas + "COM", # pydocstyle "D", # flake8-future-annotations @@ -300,6 +308,8 @@ extend-select = [ ignore = [ # Ones we want to ignore + # Trailing comma missing + "COM812", # Missing docstring in public module "D100", # Missing docstring in public class @@ -392,8 +402,15 @@ ignore = [ [tool.ruff.lint.flake8-tidy-imports] banned-module-level-imports = [ "twisted.internet.reactor", + # indirectly imports twisted.conch.insults.helper which imports twisted.internet.reactor + "twisted.conch.manhole", + # directly imports twisted.internet.reactor + "twisted.protocols.ftp", ] +[tool.ruff.lint.isort] +split-on-trailing-comma = false + [tool.ruff.lint.per-file-ignores] # Circular import workarounds "scrapy/linkextractors/__init__.py" = ["E402"] diff --git a/scrapy/VERSION b/scrapy/VERSION index 0e83a9a9c..965a689ec 100644 --- a/scrapy/VERSION +++ b/scrapy/VERSION @@ -1 +1 @@ -2.13.2 +2.13.4 diff --git a/scrapy/__init__.py b/scrapy/__init__.py index 256504c9c..c5b6483e3 100644 --- a/scrapy/__init__.py +++ b/scrapy/__init__.py @@ -29,23 +29,6 @@ __version__ = (pkgutil.get_data(__package__, "VERSION") or b"").decode("ascii"). version_info = tuple(int(v) if v.isdigit() else v for v in __version__.split(".")) -def __getattr__(name: str): - if name == "twisted_version": - import warnings # pylint: disable=reimported - - from twisted import version as _txv - - from scrapy.exceptions import ScrapyDeprecationWarning - - warnings.warn( - "The scrapy.twisted_version attribute is deprecated, use twisted.version instead", - ScrapyDeprecationWarning, - ) - return _txv.major, _txv.minor, _txv.micro - - raise AttributeError - - # Ignore noisy twisted deprecation warnings warnings.filterwarnings("ignore", category=DeprecationWarning, module="twisted") diff --git a/scrapy/cmdline.py b/scrapy/cmdline.py index 2b0204071..487980abd 100644 --- a/scrapy/cmdline.py +++ b/scrapy/cmdline.py @@ -6,7 +6,7 @@ import inspect import os import sys from importlib.metadata import entry_points -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, ParamSpec import scrapy from scrapy.commands import BaseRunSpiderCommand, ScrapyCommand, ScrapyHelpFormatter @@ -20,12 +20,9 @@ from scrapy.utils.reactor import _asyncio_reactor_path if TYPE_CHECKING: from collections.abc import Callable, Iterable - # typing.ParamSpec requires Python 3.10 - from typing_extensions import ParamSpec - from scrapy.settings import BaseSettings, Settings - _P = ParamSpec("_P") +_P = ParamSpec("_P") class ScrapyArgumentParser(argparse.ArgumentParser): @@ -67,11 +64,7 @@ def _get_commands_from_entry_points( inproject: bool, group: str = "scrapy.commands" ) -> dict[str, ScrapyCommand]: cmds: dict[str, ScrapyCommand] = {} - if sys.version_info >= (3, 10): - eps = entry_points(group=group) - else: - eps = entry_points().get(group, ()) - for entry_point in eps: + for entry_point in entry_points(group=group): obj = entry_point.load() if inspect.isclass(obj): cmds[entry_point.name] = obj() diff --git a/scrapy/commands/__init__.py b/scrapy/commands/__init__.py index ddf5f950f..7381efdcd 100644 --- a/scrapy/commands/__init__.py +++ b/scrapy/commands/__init__.py @@ -7,6 +7,7 @@ from __future__ import annotations import argparse import builtins import os +from abc import ABC, abstractmethod from pathlib import Path from typing import TYPE_CHECKING, Any @@ -22,7 +23,7 @@ if TYPE_CHECKING: from scrapy.settings import Settings -class ScrapyCommand: +class ScrapyCommand(ABC): """Base class for all Scrapy commands.""" requires_project: bool = False @@ -48,6 +49,7 @@ class ScrapyCommand: """ return "" + @abstractmethod def short_desc(self) -> str: """ A short description of the command @@ -130,6 +132,7 @@ class ScrapyCommand: if opts.pdb: failure.startDebugMode() + @abstractmethod def run(self, args: list[str], opts: argparse.Namespace) -> None: """ Entry point for running commands diff --git a/scrapy/commands/bench.py b/scrapy/commands/bench.py index 96bb1ae84..c4e277a60 100644 --- a/scrapy/commands/bench.py +++ b/scrapy/commands/bench.py @@ -10,6 +10,7 @@ import scrapy from scrapy.commands import ScrapyCommand from scrapy.http import Response, TextResponse from scrapy.linkextractors import LinkExtractor +from scrapy.utils.test import get_testenv if TYPE_CHECKING: import argparse @@ -35,8 +36,6 @@ class Command(ScrapyCommand): class _BenchServer: def __enter__(self) -> None: - from scrapy.utils.test import get_testenv - pargs = [sys.executable, "-u", "-m", "scrapy.utils.benchserver"] self.proc = subprocess.Popen( # noqa: S603 pargs, stdout=subprocess.PIPE, env=get_testenv() diff --git a/scrapy/commands/genspider.py b/scrapy/commands/genspider.py index 0e90c3188..9d2742afd 100644 --- a/scrapy/commands/genspider.py +++ b/scrapy/commands/genspider.py @@ -206,7 +206,7 @@ class Command(ScrapyCommand): # a file with the same name exists in the target directory spiders_module = import_module(self.settings["NEWSPIDER_MODULE"]) - spiders_dir = Path(cast(str, spiders_module.__file__)).parent + spiders_dir = Path(cast("str", spiders_module.__file__)).parent spiders_dir_abs = spiders_dir.resolve() path = spiders_dir_abs / (name + ".py") if path.exists(): diff --git a/scrapy/commands/parse.py b/scrapy/commands/parse.py index c4b3d2af9..e1e027c95 100644 --- a/scrapy/commands/parse.py +++ b/scrapy/commands/parse.py @@ -15,7 +15,7 @@ from scrapy.exceptions import UsageError from scrapy.http import Request, Response from scrapy.utils import display from scrapy.utils.asyncgen import collect_asyncgen -from scrapy.utils.defer import aiter_errback, deferred_from_coro +from scrapy.utils.defer import _schedule_coro, aiter_errback, deferred_from_coro from scrapy.utils.log import failure_to_exc_info from scrapy.utils.misc import arg_to_iter from scrapy.utils.spider import spidercls_for_request @@ -284,8 +284,12 @@ class Command(BaseRunSpiderCommand): if opts.pipelines: assert self.pcrawler.engine itemproc = self.pcrawler.engine.scraper.itemproc - for item in items: - itemproc.process_item(item, spider) + if hasattr(itemproc, "process_item_async"): + for item in items: + _schedule_coro(itemproc.process_item_async(item)) + else: + for item in items: + itemproc.process_item(item, spider) self.add_items(depth, items) self.add_requests(depth, requests) diff --git a/scrapy/commands/shell.py b/scrapy/commands/shell.py index eedaeb263..080f62382 100644 --- a/scrapy/commands/shell.py +++ b/scrapy/commands/shell.py @@ -12,6 +12,7 @@ from typing import TYPE_CHECKING, Any from scrapy.commands import ScrapyCommand from scrapy.http import Request from scrapy.shell import Shell +from scrapy.utils.defer import _schedule_coro from scrapy.utils.spider import DefaultSpider, spidercls_for_request from scrapy.utils.url import guess_scheme @@ -56,7 +57,7 @@ class Command(ScrapyCommand): help="do not handle HTTP 3xx status codes and print response as-is", ) - def update_vars(self, vars: dict[str, Any]) -> None: + def update_vars(self, vars: dict[str, Any]) -> None: # noqa: A002 """You can use this function to update the Scrapy objects that will be available in the shell """ @@ -84,7 +85,7 @@ class Command(ScrapyCommand): crawler._apply_settings() # The Shell class needs a persistent engine in the crawler crawler.engine = crawler._create_engine() - crawler.engine.start(_start_request_processing=False) + _schedule_coro(crawler.engine.start_async(_start_request_processing=False)) self._start_crawler_thread() diff --git a/scrapy/contracts/__init__.py b/scrapy/contracts/__init__.py index bdb68c4ad..f5ba5ba13 100644 --- a/scrapy/contracts/__init__.py +++ b/scrapy/contracts/__init__.py @@ -52,7 +52,7 @@ class Contract: cb_result = cb(response, **cb_kwargs) if isinstance(cb_result, (AsyncGenerator, CoroutineType)): raise TypeError("Contracts don't support async callbacks") - return list(cast(Iterable[Any], iterate_spider_output(cb_result))) + return list(cast("Iterable[Any]", iterate_spider_output(cb_result))) request.callback = wrapper @@ -68,7 +68,7 @@ class Contract: cb_result = cb(response, **cb_kwargs) if isinstance(cb_result, (AsyncGenerator, CoroutineType)): raise TypeError("Contracts don't support async callbacks") - output = list(cast(Iterable[Any], iterate_spider_output(cb_result))) + output = list(cast("Iterable[Any]", iterate_spider_output(cb_result))) try: results.startTest(self.testcase_post) self.post_process(output) @@ -181,7 +181,7 @@ class ContractsManager: def cb_wrapper(response: Response, **cb_kwargs: Any) -> None: try: output = cb(response, **cb_kwargs) - output = list(cast(Iterable[Any], iterate_spider_output(output))) + output = list(cast("Iterable[Any]", iterate_spider_output(output))) except Exception: case = _create_testcase(method, "callback") results.addError(case, sys.exc_info()) diff --git a/scrapy/contracts/default.py b/scrapy/contracts/default.py index 6f357ba20..90f054a87 100644 --- a/scrapy/contracts/default.py +++ b/scrapy/contracts/default.py @@ -1,7 +1,7 @@ from __future__ import annotations import json -from typing import Any, Callable +from typing import TYPE_CHECKING, Any from itemadapter import ItemAdapter, is_item @@ -9,6 +9,9 @@ from scrapy.contracts import Contract from scrapy.exceptions import ContractFail from scrapy.http import Request +if TYPE_CHECKING: + from collections.abc import Callable + # contracts class UrlContract(Contract): diff --git a/scrapy/core/downloader/__init__.py b/scrapy/core/downloader/__init__.py index 4b5654826..77b17287d 100644 --- a/scrapy/core/downloader/__init__.py +++ b/scrapy/core/downloader/__init__.py @@ -8,6 +8,7 @@ from time import time from typing import TYPE_CHECKING, Any, cast from twisted.internet.defer import Deferred, inlineCallbacks +from twisted.python.failure import Failure from scrapy import Request, Spider, signals from scrapy.core.downloader.handlers import DownloadHandlers @@ -20,10 +21,11 @@ from scrapy.utils.asyncio import ( call_later, create_looping_call, ) +from scrapy.utils.decorators import _warn_spider_arg from scrapy.utils.defer import ( - deferred_from_coro, + _defer_sleep_async, + _schedule_coro, maybe_deferred_to_future, - mustbe_deferred, ) from scrapy.utils.httpobj import urlparse_cached @@ -96,6 +98,13 @@ def _get_concurrency_delay( delay = spider.download_delay if hasattr(spider, "max_concurrent_requests"): + warnings.warn( + "The 'max_concurrent_requests' spider attribute is deprecated. " + "Use Spider.custom_settings or Spider.update_settings() instead. " + "The corresponding setting name is 'CONCURRENT_REQUESTS'.", + category=ScrapyDeprecationWarning, + stacklevel=2, + ) concurrency = spider.max_concurrent_requests return concurrency, delay @@ -105,6 +114,7 @@ class Downloader: DOWNLOAD_SLOT = "download_slot" def __init__(self, crawler: Crawler): + self.crawler: Crawler = crawler self.settings: BaseSettings = crawler.settings self.signals: SignalManager = crawler.signals self.slots: dict[str, Slot] = {} @@ -128,28 +138,30 @@ class Downloader: ) @inlineCallbacks + @_warn_spider_arg def fetch( - self, request: Request, spider: Spider + self, request: Request, spider: Spider | None = None ) -> Generator[Deferred[Any], Any, Response | Request]: self.active.add(request) try: - return ( - yield self.middleware.download(self._enqueue_request, request, spider) - ) + return (yield self.middleware.download(self._enqueue_request, request)) finally: self.active.remove(request) def needs_backout(self) -> bool: return len(self.active) >= self.total_concurrency - def _get_slot(self, request: Request, spider: Spider) -> tuple[str, Slot]: + def _get_slot(self, request: Request) -> tuple[str, Slot]: key = self.get_slot_key(request) if key not in self.slots: + assert self.crawler.spider slot_settings = self.per_slot_settings.get(key, {}) conc = ( self.ip_concurrency if self.ip_concurrency else self.domain_concurrency ) - conc, delay = _get_concurrency_delay(conc, spider, self.settings) + conc, delay = _get_concurrency_delay( + conc, self.crawler.spider, self.settings + ) conc, delay = ( slot_settings.get("concurrency", conc), slot_settings.get("delay", delay), @@ -162,7 +174,7 @@ class Downloader: def get_slot_key(self, request: Request) -> str: if self.DOWNLOAD_SLOT in request.meta: - return cast(str, request.meta[self.DOWNLOAD_SLOT]) + return cast("str", request.meta[self.DOWNLOAD_SLOT]) key = urlparse_cached(request).hostname or "" if self.ip_concurrency: @@ -170,33 +182,28 @@ class Downloader: return key - def _get_slot_key(self, request: Request, spider: Spider | None) -> str: - warnings.warn( - "Use of this protected method is deprecated. Consider using its corresponding public method get_slot_key() instead.", - ScrapyDeprecationWarning, - stacklevel=2, - ) - return self.get_slot_key(request) - + # passed as download_func into self.middleware.download() in self.fetch() @inlineCallbacks def _enqueue_request( - self, request: Request, spider: Spider + self, request: Request ) -> Generator[Deferred[Any], Any, Response]: - key, slot = self._get_slot(request, spider) + key, slot = self._get_slot(request) request.meta[self.DOWNLOAD_SLOT] = key slot.active.add(request) self.signals.send_catch_log( - signal=signals.request_reached_downloader, request=request, spider=spider + signal=signals.request_reached_downloader, + request=request, + spider=self.crawler.spider, ) d: Deferred[Response] = Deferred() slot.queue.append((request, d)) - self._process_queue(spider, slot) + self._process_queue(slot) try: - return (yield d) + return (yield d) # fired in _wait_for_download() finally: slot.active.remove(request) - def _process_queue(self, spider: Spider, slot: Slot) -> None: + def _process_queue(self, slot: Slot) -> None: if slot.latercall: # block processing until slot.latercall is called return @@ -207,31 +214,30 @@ class Downloader: if delay: penalty = delay - now + slot.lastseen if penalty > 0: - slot.latercall = call_later(penalty, self._latercall, spider, slot) + slot.latercall = call_later(penalty, self._latercall, slot) return # Process enqueued requests if there are free slots to transfer for this slot while slot.queue and slot.free_transfer_slots() > 0: slot.lastseen = now - request, deferred = slot.queue.popleft() - dfd = deferred_from_coro(self._download(slot, request, spider)) - dfd.chainDeferred(deferred) + request, queue_dfd = slot.queue.popleft() + _schedule_coro(self._wait_for_download(slot, request, queue_dfd)) # prevent burst if inter-request delays were configured if delay: - self._process_queue(spider, slot) + self._process_queue(slot) break - def _latercall(self, spider: Spider, slot: Slot) -> None: + def _latercall(self, slot: Slot) -> None: slot.latercall = None - self._process_queue(spider, slot) + self._process_queue(slot) - async def _download(self, slot: Slot, request: Request, spider: Spider) -> Response: + async def _download(self, slot: Slot, request: Request) -> Response: # The order is very important for the following logic. Do not change! slot.transferring.add(request) try: # 1. Download the response response: Response = await maybe_deferred_to_future( - mustbe_deferred(self.handlers.download_request, request, spider) + self.handlers.download_request(request) ) # 2. Notify response_downloaded listeners about the recent download # before querying queue for next request @@ -239,20 +245,35 @@ class Downloader: signal=signals.response_downloaded, response=response, request=request, - spider=spider, + spider=self.crawler.spider, ) return response + except Exception: + await _defer_sleep_async() + raise finally: # 3. After response arrives, remove the request from transferring # state to free up the transferring slot so it can be used by the # following requests (perhaps those which came from the downloader # middleware itself) slot.transferring.remove(request) - self._process_queue(spider, slot) + self._process_queue(slot) self.signals.send_catch_log( - signal=signals.request_left_downloader, request=request, spider=spider + signal=signals.request_left_downloader, + request=request, + spider=self.crawler.spider, ) + async def _wait_for_download( + self, slot: Slot, request: Request, queue_dfd: Deferred[Response] + ) -> None: + try: + response = await self._download(slot, request) + except Exception: + queue_dfd.errback(Failure()) + else: + queue_dfd.callback(response) # awaited in _enqueue_request() + def close(self) -> None: self._slot_gc_loop.stop() for slot in self.slots.values(): diff --git a/scrapy/core/downloader/contextfactory.py b/scrapy/core/downloader/contextfactory.py index d1ba6208a..5f7c9a8c3 100644 --- a/scrapy/core/downloader/contextfactory.py +++ b/scrapy/core/downloader/contextfactory.py @@ -71,21 +71,6 @@ class ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): stacklevel=2, ) - @classmethod - def from_settings( - cls, - settings: BaseSettings, - method: int = SSL.SSLv23_METHOD, - *args: Any, - **kwargs: Any, - ) -> Self: - warnings.warn( - f"{cls.__name__}.from_settings() is deprecated, use from_crawler() instead.", - category=ScrapyDeprecationWarning, - stacklevel=2, - ) - return cls._from_settings(settings, method, *args, **kwargs) - @classmethod def from_crawler( cls, @@ -94,20 +79,10 @@ class ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): *args: Any, **kwargs: Any, ) -> Self: - return cls._from_settings(crawler.settings, method, *args, **kwargs) - - @classmethod - def _from_settings( - cls, - settings: BaseSettings, - method: int = SSL.SSLv23_METHOD, - *args: Any, - **kwargs: Any, - ) -> Self: - tls_verbose_logging: bool = settings.getbool( + tls_verbose_logging: bool = crawler.settings.getbool( "DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING" ) - tls_ciphers: str | None = settings["DOWNLOADER_CLIENT_TLS_CIPHERS"] + tls_ciphers: str | None = crawler.settings["DOWNLOADER_CLIENT_TLS_CIPHERS"] return cls( # type: ignore[misc] method=method, tls_verbose_logging=tls_verbose_logging, diff --git a/scrapy/core/downloader/handlers/__init__.py b/scrapy/core/downloader/handlers/__init__.py index 902f200b8..6352ede3c 100644 --- a/scrapy/core/downloader/handlers/__init__.py +++ b/scrapy/core/downloader/handlers/__init__.py @@ -9,6 +9,7 @@ from twisted.internet import defer from scrapy import Request, Spider, signals from scrapy.exceptions import NotConfigured, NotSupported +from scrapy.utils.decorators import _warn_spider_arg from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.misc import build_from_crawler, load_object from scrapy.utils.python import without_none_values @@ -93,14 +94,18 @@ class DownloadHandlers: self._handlers[scheme] = dh return dh - def download_request(self, request: Request, spider: Spider) -> Deferred[Response]: + @_warn_spider_arg + def download_request( + self, request: Request, spider: Spider | None = None + ) -> Deferred[Response]: scheme = urlparse_cached(request).scheme handler = self._get_handler(scheme) if not handler: raise NotSupported( f"Unsupported URL scheme '{scheme}': {self._notconfigured[scheme]}" ) - return handler.download_request(request, spider) + assert self._crawler.spider + return handler.download_request(request, self._crawler.spider) @defer.inlineCallbacks def _close(self, *_a: Any, **_kw: Any) -> Generator[Deferred[Any], Any, None]: diff --git a/scrapy/core/downloader/handlers/ftp.py b/scrapy/core/downloader/handlers/ftp.py index 0ad10baff..1d947b1e3 100644 --- a/scrapy/core/downloader/handlers/ftp.py +++ b/scrapy/core/downloader/handlers/ftp.py @@ -37,7 +37,6 @@ from typing import TYPE_CHECKING, Any, BinaryIO from urllib.parse import unquote from twisted.internet.protocol import ClientCreator, Protocol -from twisted.protocols.ftp import CommandFailed, FTPClient from scrapy.http import Response from scrapy.responsetypes import responsetypes @@ -46,6 +45,7 @@ from scrapy.utils.python import to_bytes if TYPE_CHECKING: from twisted.internet.defer import Deferred + from twisted.protocols.ftp import FTPClient from twisted.python.failure import Failure # typing.Self requires Python 3.11 @@ -101,6 +101,7 @@ class FTPDownloadHandler: def download_request(self, request: Request, spider: Spider) -> Deferred[Response]: from twisted.internet import reactor + from twisted.protocols.ftp import FTPClient parsed_url = urlparse_cached(request) user = request.meta.get("ftp_user", self.default_user) @@ -138,6 +139,8 @@ class FTPDownloadHandler: return respcls(url=request.url, status=200, body=body, headers=headers) # type: ignore[arg-type] def _failed(self, result: Failure, request: Request) -> Response: + from twisted.protocols.ftp import CommandFailed + message = result.getErrorMessage() if result.type == CommandFailed: m = _CODE_RE.search(message) diff --git a/scrapy/core/downloader/handlers/http.py b/scrapy/core/downloader/handlers/http.py index 93b96c779..bc343e37f 100644 --- a/scrapy/core/downloader/handlers/http.py +++ b/scrapy/core/downloader/handlers/http.py @@ -1,7 +1,18 @@ +import warnings + from scrapy.core.downloader.handlers.http10 import HTTP10DownloadHandler from scrapy.core.downloader.handlers.http11 import ( HTTP11DownloadHandler as HTTPDownloadHandler, ) +from scrapy.exceptions import ScrapyDeprecationWarning + +warnings.warn( + "The scrapy.core.downloader.handlers.http module is deprecated," + " please import scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler" + " instead of its deprecated alias scrapy.core.downloader.handlers.http.HTTPDownloadHandler", + ScrapyDeprecationWarning, + stacklevel=2, +) __all__ = [ "HTTP10DownloadHandler", diff --git a/scrapy/core/downloader/handlers/http11.py b/scrapy/core/downloader/handlers/http11.py index 54fef48b6..d8965c130 100644 --- a/scrapy/core/downloader/handlers/http11.py +++ b/scrapy/core/downloader/handlers/http11.py @@ -14,7 +14,7 @@ from urllib.parse import urldefrag, urlparse from twisted.internet import ssl from twisted.internet.defer import CancelledError, Deferred, succeed from twisted.internet.endpoints import TCP4ClientEndpoint -from twisted.internet.error import TimeoutError +from twisted.internet.error import TimeoutError as TxTimeoutError from twisted.internet.protocol import Factory, Protocol, connectionDone from twisted.python.failure import Failure from twisted.web.client import ( @@ -386,6 +386,7 @@ class ScrapyAgent: if not proxy_port: proxy_port = 443 if proxy_parsed.scheme == "https" else 80 if urlparse_cached(request).scheme == "https": + assert proxy_host is not None proxyAuth = request.headers.get(b"Proxy-Authorization", None) proxyConf = (proxy_host, proxy_port, proxyAuth) return self._TunnelingAgent( @@ -430,7 +431,7 @@ class ScrapyAgent: method, to_bytes(url, encoding="ascii"), headers, - cast(IBodyProducer, bodyproducer), + cast("IBodyProducer", bodyproducer), ) # set download latency d.addCallback(self._cb_latency, request, start_time) @@ -451,7 +452,7 @@ class ScrapyAgent: if self._txresponse: self._txresponse._transport.stopProducing() - raise TimeoutError(f"Getting {url} took longer than {timeout} seconds.") + raise TxTimeoutError(f"Getting {url} took longer than {timeout} seconds.") def _cb_latency(self, result: _T, request: Request, start_time: float) -> _T: request.meta["download_latency"] = time() - start_time diff --git a/scrapy/core/downloader/handlers/http2.py b/scrapy/core/downloader/handlers/http2.py index d0a95ee9d..8e6236094 100644 --- a/scrapy/core/downloader/handlers/http2.py +++ b/scrapy/core/downloader/handlers/http2.py @@ -4,7 +4,7 @@ from time import time from typing import TYPE_CHECKING from urllib.parse import urldefrag -from twisted.internet.error import TimeoutError +from twisted.internet.error import TimeoutError as TxTimeoutError from twisted.web.client import URI from scrapy.core.downloader.contextfactory import load_context_factory_from_settings @@ -127,4 +127,4 @@ class ScrapyH2Agent: return response url = urldefrag(request.url)[0] - raise TimeoutError(f"Getting {url} took longer than {timeout} seconds.") + raise TxTimeoutError(f"Getting {url} took longer than {timeout} seconds.") diff --git a/scrapy/core/downloader/handlers/s3.py b/scrapy/core/downloader/handlers/s3.py index 870a26f04..05a71b742 100644 --- a/scrapy/core/downloader/handlers/s3.py +++ b/scrapy/core/downloader/handlers/s3.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any -from scrapy.core.downloader.handlers.http import HTTPDownloadHandler +from scrapy.core.downloader.handlers.http11 import HTTP11DownloadHandler from scrapy.exceptions import NotConfigured from scrapy.utils.boto import is_botocore_available from scrapy.utils.httpobj import urlparse_cached @@ -29,7 +29,7 @@ class S3DownloadHandler: aws_access_key_id: str | None = None, aws_secret_access_key: str | None = None, aws_session_token: str | None = None, - httpdownloadhandler: type[HTTPDownloadHandler] = HTTPDownloadHandler, + httpdownloadhandler: type[HTTP11DownloadHandler] = HTTP11DownloadHandler, **kw: Any, ): if not is_botocore_available(): @@ -51,8 +51,8 @@ class S3DownloadHandler: self.anon = kw.get("anon") self._signer = None - import botocore.auth - import botocore.credentials + import botocore.auth # noqa: PLC0415 + import botocore.credentials # noqa: PLC0415 kw.pop("anon", None) if kw: @@ -87,7 +87,7 @@ class S3DownloadHandler: if self.anon: request = request.replace(url=url) else: - import botocore.awsrequest + import botocore.awsrequest # noqa: PLC0415 awsrequest = botocore.awsrequest.AWSRequest( method=request.method, diff --git a/scrapy/core/downloader/middleware.py b/scrapy/core/downloader/middleware.py index a4055849d..a822ba811 100644 --- a/scrapy/core/downloader/middleware.py +++ b/scrapy/core/downloader/middleware.py @@ -6,19 +6,20 @@ See documentation in docs/topics/downloader-middleware.rst from __future__ import annotations -from collections.abc import Callable +import warnings from typing import TYPE_CHECKING, Any, cast from twisted.internet.defer import Deferred, inlineCallbacks -from scrapy.exceptions import _InvalidOutput +from scrapy.exceptions import ScrapyDeprecationWarning, _InvalidOutput from scrapy.http import Request, Response from scrapy.middleware import MiddlewareManager from scrapy.utils.conf import build_component_list -from scrapy.utils.defer import deferred_from_coro, mustbe_deferred +from scrapy.utils.defer import _defer_sleep, deferred_from_coro +from scrapy.utils.deprecate import argument_is_required if TYPE_CHECKING: - from collections.abc import Generator + from collections.abc import Callable, Generator from scrapy import Spider from scrapy.settings import BaseSettings @@ -34,27 +35,44 @@ class DownloaderMiddlewareManager(MiddlewareManager): def _add_middleware(self, mw: Any) -> None: if hasattr(mw, "process_request"): self.methods["process_request"].append(mw.process_request) + self._check_mw_method_spider_arg(mw.process_request) if hasattr(mw, "process_response"): self.methods["process_response"].appendleft(mw.process_response) + self._check_mw_method_spider_arg(mw.process_response) if hasattr(mw, "process_exception"): self.methods["process_exception"].appendleft(mw.process_exception) + self._check_mw_method_spider_arg(mw.process_exception) @inlineCallbacks def download( self, - download_func: Callable[[Request, Spider], Deferred[Response]], + download_func: Callable[[Request], Deferred[Response]], request: Request, - spider: Spider, + spider: Spider | None = None, ) -> Generator[Deferred[Any], Any, Response | Request]: + if argument_is_required(download_func, "spider"): + warnings.warn( + "The spider argument of download_func is deprecated" + " and will not be passed in future Scrapy versions.", + ScrapyDeprecationWarning, + stacklevel=2, + ) + need_spider_arg = True + else: + need_spider_arg = False + @inlineCallbacks def process_request( request: Request, ) -> Generator[Deferred[Any], Any, Response | Request]: for method in self.methods["process_request"]: - method = cast(Callable, method) - response = yield deferred_from_coro( - method(request=request, spider=spider) - ) + method = cast("Callable", method) + if method in self._mw_methods_requiring_spider: + response = yield deferred_from_coro( + method(request=request, spider=self._spider) + ) + else: + response = yield deferred_from_coro(method(request=request)) if response is not None and not isinstance( response, (Response, Request) ): @@ -64,7 +82,9 @@ class DownloaderMiddlewareManager(MiddlewareManager): ) if response: return response - return (yield download_func(request, spider)) + if need_spider_arg: + return (yield download_func(request, self._spider)) # type: ignore[call-arg] + return (yield download_func(request)) @inlineCallbacks def process_response( @@ -76,10 +96,15 @@ class DownloaderMiddlewareManager(MiddlewareManager): return response for method in self.methods["process_response"]: - method = cast(Callable, method) - response = yield deferred_from_coro( - method(request=request, response=response, spider=spider) - ) + method = cast("Callable", method) + if method in self._mw_methods_requiring_spider: + response = yield deferred_from_coro( + method(request=request, response=response, spider=self._spider) + ) + else: + response = yield deferred_from_coro( + method(request=request, response=response) + ) if not isinstance(response, (Response, Request)): raise _InvalidOutput( f"Middleware {method.__qualname__} must return Response or Request, " @@ -94,10 +119,17 @@ class DownloaderMiddlewareManager(MiddlewareManager): exception: Exception, ) -> Generator[Deferred[Any], Any, Response | Request]: for method in self.methods["process_exception"]: - method = cast(Callable, method) - response = yield deferred_from_coro( - method(request=request, exception=exception, spider=spider) - ) + method = cast("Callable", method) + if method in self._mw_methods_requiring_spider: + response = yield deferred_from_coro( + method( + request=request, exception=exception, spider=self._spider + ) + ) + else: + response = yield deferred_from_coro( + method(request=request, exception=exception) + ) if response is not None and not isinstance( response, (Response, Request) ): @@ -109,9 +141,13 @@ class DownloaderMiddlewareManager(MiddlewareManager): return response raise exception + if spider: + self._warn_spider_arg("download") + self._set_compat_spider(spider) try: - result: Response | Request = yield mustbe_deferred(process_request, request) + result: Response | Request = yield process_request(request) except Exception as ex: + yield _defer_sleep() # either returns a request or response (which we pass to process_response()) # or reraises the exception result = yield process_exception(ex) diff --git a/scrapy/core/engine.py b/scrapy/core/engine.py index fe635dc82..9679d780d 100644 --- a/scrapy/core/engine.py +++ b/scrapy/core/engine.py @@ -7,38 +7,49 @@ For more information see docs/topics/architecture.rst from __future__ import annotations +import asyncio import logging +import warnings from time import time from traceback import format_exc -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any -from twisted.internet.defer import Deferred, inlineCallbacks, succeed +from twisted.internet.defer import CancelledError, Deferred, inlineCallbacks from twisted.python.failure import Failure from scrapy import signals +from scrapy.core.scheduler import BaseScheduler from scrapy.core.scraper import Scraper -from scrapy.exceptions import CloseSpider, DontCloseSpider, IgnoreRequest +from scrapy.exceptions import ( + CloseSpider, + DontCloseSpider, + IgnoreRequest, + ScrapyDeprecationWarning, +) from scrapy.http import Request, Response from scrapy.utils.asyncio import ( AsyncioLoopingCall, create_looping_call, + is_asyncio_available, ) from scrapy.utils.defer import ( - deferred_f_from_coro_f, + _schedule_coro, deferred_from_coro, + ensure_awaitable, maybe_deferred_to_future, ) +from scrapy.utils.deprecate import argument_is_required from scrapy.utils.log import failure_to_exc_info, logformatter_adapter from scrapy.utils.misc import build_from_crawler, load_object +from scrapy.utils.python import global_object_name from scrapy.utils.reactor import CallLaterOnce if TYPE_CHECKING: - from collections.abc import AsyncIterator, Callable, Generator + from collections.abc import AsyncIterator, Callable, Coroutine, Generator from twisted.internet.task import LoopingCall from scrapy.core.downloader import Downloader - from scrapy.core.scheduler import BaseScheduler from scrapy.crawler import Crawler from scrapy.logformatter import LogFormatter from scrapy.settings import BaseSettings, Settings @@ -72,10 +83,10 @@ class _Slot: self.inprogress.remove(request) self._maybe_fire_closing() - def close(self) -> Deferred[None]: + async def close(self) -> None: self.closing = Deferred() self._maybe_fire_closing() - return self.closing + await maybe_deferred_to_future(self.closing) def _maybe_fire_closing(self) -> None: if self.closing is not None and not self.inprogress: @@ -92,7 +103,9 @@ class ExecutionEngine: def __init__( self, crawler: Crawler, - spider_closed_callback: Callable[[Spider], Deferred[None] | None], + spider_closed_callback: Callable[ + [Spider], Coroutine[Any, Any, None] | Deferred[None] | None + ], ) -> None: self.crawler: Crawler = crawler self.settings: Settings = crawler.settings @@ -103,25 +116,39 @@ class ExecutionEngine: self.spider: Spider | None = None self.running: bool = False self.paused: bool = False - self._spider_closed_callback: Callable[[Spider], Deferred[None] | None] = ( - spider_closed_callback - ) + self._spider_closed_callback: Callable[ + [Spider], Coroutine[Any, Any, None] | Deferred[None] | None + ] = spider_closed_callback self.start_time: float | None = None self._start: AsyncIterator[Any] | None = None + self._closewait: Deferred[None] | None = None + self._start_request_processing_awaitable: ( + asyncio.Future[None] | Deferred[None] | None + ) = None downloader_cls: type[Downloader] = load_object(self.settings["DOWNLOADER"]) try: self.scheduler_cls: type[BaseScheduler] = self._get_scheduler_class( crawler.settings ) self.downloader: Downloader = downloader_cls(crawler) + self._downloader_fetch_needs_spider: bool = argument_is_required( + self.downloader.fetch, "spider" + ) + if self._downloader_fetch_needs_spider: + warnings.warn( + f"The fetch() method of {global_object_name(downloader_cls)} requires a spider argument," + f" this is deprecated and the argument will not be passed in future Scrapy versions.", + ScrapyDeprecationWarning, + stacklevel=2, + ) + self.scraper: Scraper = Scraper(crawler) except Exception: - self.close() + if hasattr(self, "downloader"): + self.downloader.close() raise def _get_scheduler_class(self, settings: BaseSettings) -> type[BaseScheduler]: - from scrapy.core.scheduler import BaseScheduler - scheduler_cls: type[BaseScheduler] = load_object(settings["SCHEDULER"]) if not issubclass(scheduler_cls, BaseScheduler): raise TypeError( @@ -131,52 +158,87 @@ class ExecutionEngine: return scheduler_cls def start(self, _start_request_processing=True) -> Deferred[None]: - return deferred_from_coro(self.start_async(_start_request_processing)) + warnings.warn( + "ExecutionEngine.start() is deprecated, use start_async() instead", + ScrapyDeprecationWarning, + stacklevel=2, + ) + return deferred_from_coro( + self.start_async(_start_request_processing=_start_request_processing) + ) - async def start_async(self, _start_request_processing=True) -> None: + async def start_async(self, *, _start_request_processing: bool = True) -> None: + """Start the execution engine. + + .. versionadded:: VERSION + """ if self.running: raise RuntimeError("Engine already running") self.start_time = time() await self.signals.send_catch_log_async(signal=signals.engine_started) + if _start_request_processing and self.spider is None: + # require an opened spider when not run in scrapy shell + return self.running = True - self._closewait: Deferred[None] = Deferred() + self._closewait = Deferred() if _start_request_processing: - self._start_request_processing() + coro = self._start_request_processing() + if is_asyncio_available(): + # not wrapping in a Deferred here to avoid https://github.com/twisted/twisted/issues/12470 + # (can happen when this is cancelled, e.g. in test_close_during_start_iteration()) + self._start_request_processing_awaitable = asyncio.ensure_future(coro) + else: + self._start_request_processing_awaitable = Deferred.fromCoroutine(coro) await maybe_deferred_to_future(self._closewait) def stop(self) -> Deferred[None]: - """Gracefully stop the execution engine""" + warnings.warn( + "ExecutionEngine.stop() is deprecated, use stop_async() instead", + ScrapyDeprecationWarning, + stacklevel=2, + ) + return deferred_from_coro(self.stop_async()) - @deferred_f_from_coro_f - async def _finish_stopping_engine(_: Any) -> None: - await self.signals.send_catch_log_async(signal=signals.engine_stopped) - self._closewait.callback(None) + async def stop_async(self) -> None: + """Gracefully stop the execution engine. + + .. versionadded:: VERSION + """ if not self.running: raise RuntimeError("Engine not running") self.running = False - dfd = ( - self.close_spider(self.spider, reason="shutdown") - if self.spider is not None - else succeed(None) - ) - return dfd.addBoth(_finish_stopping_engine) + if self._start_request_processing_awaitable is not None: + self._start_request_processing_awaitable.cancel() + self._start_request_processing_awaitable = None + if self.spider is not None: + await self.close_spider_async(reason="shutdown") + await self.signals.send_catch_log_async(signal=signals.engine_stopped) + if self._closewait: + self._closewait.callback(None) def close(self) -> Deferred[None]: + warnings.warn( + "ExecutionEngine.close() is deprecated, use close_async() instead", + ScrapyDeprecationWarning, + stacklevel=2, + ) + return deferred_from_coro(self.close_async()) + + async def close_async(self) -> None: """ Gracefully close the execution engine. If it has already been started, stop it. In all cases, close the spider and the downloader. """ if self.running: - return self.stop() # will also close spider and downloader - if self.spider is not None: - return self.close_spider( - self.spider, reason="shutdown" + await self.stop_async() # will also close spider and downloader + elif self.spider is not None: + await self.close_spider_async( + reason="shutdown" ) # will also close downloader - if hasattr(self, "downloader"): + elif hasattr(self, "downloader"): self.downloader.close() - return succeed(None) def pause(self) -> None: self.paused = True @@ -207,27 +269,41 @@ class ExecutionEngine: if isinstance(item_or_request, Request): self.crawl(item_or_request) else: - self.scraper.start_itemproc(item_or_request, response=None) + _schedule_coro( + self.scraper.start_itemproc_async(item_or_request, response=None) + ) self._slot.nextcall.schedule() - @deferred_f_from_coro_f async def _start_request_processing(self) -> None: """Starts consuming Spider.start() output and sending scheduled requests.""" # Starts the processing of scheduled requests, as well as a periodic # call to that processing method for scenarios where the scheduler # reports having pending requests but returns none. - assert self._slot is not None # typing - self._slot.nextcall.schedule() - self._slot.heartbeat.start(self._SLOT_HEARTBEAT_INTERVAL) + try: + assert self._slot is not None # typing + self._slot.nextcall.schedule() + self._slot.heartbeat.start(self._SLOT_HEARTBEAT_INTERVAL) - while self._start and self.spider: - await self._process_start_next() - if not self.needs_backout(): - # Give room for the outcome of self._process_start_next() to be - # processed before continuing with the next iteration. - self._slot.nextcall.schedule() - await self._slot.nextcall.wait() + while self._start and self.spider: + await self._process_start_next() + if not self.needs_backout(): + # Give room for the outcome of self._process_start_next() to be + # processed before continuing with the next iteration. + self._slot.nextcall.schedule() + await self._slot.nextcall.wait() + except (asyncio.exceptions.CancelledError, CancelledError): + # self.stop() has cancelled us, nothing to do + return + except Exception: + # an error happened, log it and stop the engine + self._start_request_processing_awaitable = None + logger.error( + "Error while processing requests from start()", + exc_info=True, + extra={"spider": self.spider}, + ) + await self.stop_async() def _start_scheduled_requests(self) -> None: if self._slot is None or self._slot.closing is not None or self.paused: @@ -246,10 +322,10 @@ class ExecutionEngine: See :ref:`start-requests-lazy` for an example. """ - assert self._slot is not None # typing assert self.scraper.slot is not None # typing return ( not self.running + or not self._slot or bool(self._slot.closing) or self.downloader.needs_backout() or self.scraper.slot.needs_backout() @@ -354,18 +430,33 @@ class ExecutionEngine: signals.request_dropped, request=request, spider=self.spider ) - @inlineCallbacks - def download(self, request: Request) -> Generator[Deferred[Any], Any, Response]: + def download(self, request: Request) -> Deferred[Response]: """Return a Deferred which fires with a Response as result, only downloader middlewares are applied""" + warnings.warn( + "ExecutionEngine.download() is deprecated, use download_async() instead", + ScrapyDeprecationWarning, + stacklevel=2, + ) + return deferred_from_coro(self.download_async(request)) + + async def download_async(self, request: Request) -> Response: + """Return a coroutine which fires with a Response as result. + + Only downloader middlewares are applied. + + .. versionadded:: VERSION + """ if self.spider is None: raise RuntimeError(f"No open spider to crawl: {request}") try: - response_or_request = yield self._download(request) + response_or_request = await maybe_deferred_to_future( + self._download(request) + ) finally: assert self._slot is not None self._slot.remove_request(request) if isinstance(response_or_request, Request): - return (yield self.download(response_or_request)) + return await self.download_async(response_or_request) return response_or_request @inlineCallbacks @@ -377,9 +468,11 @@ class ExecutionEngine: self._slot.add_request(request) try: - result: Response | Request = yield self.downloader.fetch( - request, self.spider - ) + result: Response | Request + if self._downloader_fetch_needs_spider: + result = yield self.downloader.fetch(request, self.spider) + else: + result = yield self.downloader.fetch(request) if not isinstance(result, (Response, Request)): raise TypeError( f"Incorrect type: expected Response or Request, got {type(result)}: {result!r}" @@ -387,7 +480,6 @@ class ExecutionEngine: if isinstance(result, Response): if result.request is None: result.request = request - assert self.spider is not None logkws = self.logformatter.crawled(result.request, result, self.spider) if logkws is not None: logger.log( @@ -404,30 +496,33 @@ class ExecutionEngine: self._slot.nextcall.schedule() def open_spider(self, spider: Spider, close_if_idle: bool = True) -> Deferred[None]: - return deferred_from_coro( - self.open_spider_async(spider, close_if_idle=close_if_idle) + warnings.warn( + "ExecutionEngine.open_spider() is deprecated, use open_spider_async() instead", + ScrapyDeprecationWarning, + stacklevel=2, ) + return deferred_from_coro(self.open_spider_async(close_if_idle=close_if_idle)) - async def open_spider_async( - self, - spider: Spider, - *, - close_if_idle: bool = True, - ) -> None: + async def open_spider_async(self, *, close_if_idle: bool = True) -> None: + assert self.crawler.spider if self._slot is not None: - raise RuntimeError(f"No free spider slot when opening {spider.name!r}") - logger.info("Spider opened", extra={"spider": spider}) - self.spider = spider + raise RuntimeError( + f"No free spider slot when opening {self.crawler.spider.name!r}" + ) + logger.info("Spider opened", extra={"spider": self.crawler.spider}) + self.spider = self.crawler.spider nextcall = CallLaterOnce(self._start_scheduled_requests) scheduler = build_from_crawler(self.scheduler_cls, self.crawler) self._slot = _Slot(close_if_idle, nextcall, scheduler) - self._start = await self.scraper.spidermw.process_start(spider) - if hasattr(scheduler, "open") and (d := scheduler.open(spider)): + self._start = await self.scraper.spidermw.process_start() + if hasattr(scheduler, "open") and (d := scheduler.open(self.crawler.spider)): await maybe_deferred_to_future(d) - await maybe_deferred_to_future(self.scraper.open_spider(spider)) + await self.scraper.open_spider_async() assert self.crawler.stats - self.crawler.stats.open_spider(spider) - await self.signals.send_catch_log_async(signals.spider_opened, spider=spider) + self.crawler.stats.open_spider() + await self.signals.send_catch_log_async( + signals.spider_opened, spider=self.crawler.spider + ) def _spider_idle(self) -> None: """ @@ -452,70 +547,87 @@ class ExecutionEngine: if self.spider_is_idle(): ex = detected_ex.get(CloseSpider, CloseSpider(reason="finished")) assert isinstance(ex, CloseSpider) # typing - self.close_spider(self.spider, reason=ex.reason) + _schedule_coro(self.close_spider_async(reason=ex.reason)) def close_spider(self, spider: Spider, reason: str = "cancelled") -> Deferred[None]: - """Close (cancel) spider and clear all its outstanding requests""" + warnings.warn( + "ExecutionEngine.close_spider() is deprecated, use close_spider_async() instead", + ScrapyDeprecationWarning, + stacklevel=2, + ) + return deferred_from_coro(self.close_spider_async(reason=reason)) + + async def close_spider_async(self, *, reason: str = "cancelled") -> None: + """Close (cancel) spider and clear all its outstanding requests. + + .. versionadded:: VERSION + """ + if self.spider is None: + raise RuntimeError("Spider not opened") + if self._slot is None: raise RuntimeError("Engine slot not assigned") if self._slot.closing is not None: - return self._slot.closing + await maybe_deferred_to_future(self._slot.closing) + return + + spider = self.spider logger.info( "Closing spider (%(reason)s)", {"reason": reason}, extra={"spider": spider} ) - dfd = self._slot.close() + def log_failure(msg: str) -> None: + logger.error(msg, exc_info=True, extra={"spider": spider}) # noqa: LOG014 - def log_failure(msg: str) -> Callable[[Failure], None]: - def errback(failure: Failure) -> None: - logger.error( - msg, exc_info=failure_to_exc_info(failure), extra={"spider": spider} - ) + try: + await self._slot.close() + except Exception: + log_failure("Slot close failure") - return errback + try: + self.downloader.close() + except Exception: + log_failure("Downloader close failure") - dfd.addBoth(lambda _: self.downloader.close()) - dfd.addErrback(log_failure("Downloader close failure")) - - dfd.addBoth(lambda _: self.scraper.close_spider()) - dfd.addErrback(log_failure("Scraper close failure")) + try: + await self.scraper.close_spider_async() + except Exception: + log_failure("Scraper close failure") if hasattr(self._slot.scheduler, "close"): - dfd.addBoth(lambda _: cast(_Slot, self._slot).scheduler.close(reason)) - dfd.addErrback(log_failure("Scheduler close failure")) + try: + if (d := self._slot.scheduler.close(reason)) is not None: + await maybe_deferred_to_future(d) + except Exception: + log_failure("Scheduler close failure") - dfd.addBoth( - lambda _: self.signals.send_catch_log_deferred( + try: + await self.signals.send_catch_log_async( signal=signals.spider_closed, spider=spider, reason=reason, ) - ) - dfd.addErrback(log_failure("Error while sending spider_close signal")) + except Exception: + log_failure("Error while sending spider_close signal") - def close_stats(_: Any) -> None: - assert self.crawler.stats - self.crawler.stats.close_spider(spider, reason=reason) + assert self.crawler.stats + try: + self.crawler.stats.close_spider(reason=reason) + except Exception: + log_failure("Stats close failure") - dfd.addBoth(close_stats) - dfd.addErrback(log_failure("Stats close failure")) - - dfd.addBoth( - lambda _: logger.info( - "Spider closed (%(reason)s)", - {"reason": reason}, - extra={"spider": spider}, - ) + logger.info( + "Spider closed (%(reason)s)", + {"reason": reason}, + extra={"spider": spider}, ) - dfd.addBoth(lambda _: setattr(self, "slot", None)) - dfd.addErrback(log_failure("Error while unassigning slot")) + self._slot = None + self.spider = None - dfd.addBoth(lambda _: setattr(self, "spider", None)) - dfd.addErrback(log_failure("Error while unassigning spider")) - - dfd.addBoth(lambda _: self._spider_closed_callback(spider)) - - return dfd + try: + await ensure_awaitable(self._spider_closed_callback(spider)) + except Exception: + log_failure("Error running spider_closed_callback") diff --git a/scrapy/core/http2/protocol.py b/scrapy/core/http2/protocol.py index 23335b7b2..cf2742de6 100644 --- a/scrapy/core/http2/protocol.py +++ b/scrapy/core/http2/protocol.py @@ -21,7 +21,7 @@ from h2.events import ( WindowUpdated, ) from h2.exceptions import FrameTooLargeError, H2Error -from twisted.internet.error import TimeoutError +from twisted.internet.error import TimeoutError as TxTimeoutError from twisted.internet.interfaces import ( IAddress, IHandshakeListener, @@ -322,7 +322,7 @@ class H2ClientProtocol(Protocol, TimeoutMixin): self._write_to_transport() self._lose_connection_with_error( - [TimeoutError(f"Connection was IDLE for more than {self.IDLE_TIMEOUT}s")] + [TxTimeoutError(f"Connection was IDLE for more than {self.IDLE_TIMEOUT}s")] ) def connectionLost(self, reason: Failure = connectionDone) -> None: diff --git a/scrapy/core/scheduler.py b/scrapy/core/scheduler.py index 9ac447289..bfca9cad4 100644 --- a/scrapy/core/scheduler.py +++ b/scrapy/core/scheduler.py @@ -348,11 +348,11 @@ class Scheduler(BaseScheduler): dqok = self._dqpush(request) assert self.stats is not None if dqok: - self.stats.inc_value("scheduler/enqueued/disk", spider=self.spider) + self.stats.inc_value("scheduler/enqueued/disk") else: self._mqpush(request) - self.stats.inc_value("scheduler/enqueued/memory", spider=self.spider) - self.stats.inc_value("scheduler/enqueued", spider=self.spider) + self.stats.inc_value("scheduler/enqueued/memory") + self.stats.inc_value("scheduler/enqueued") return True def next_request(self) -> Request | None: @@ -367,13 +367,13 @@ class Scheduler(BaseScheduler): request: Request | None = self.mqs.pop() assert self.stats is not None if request is not None: - self.stats.inc_value("scheduler/dequeued/memory", spider=self.spider) + self.stats.inc_value("scheduler/dequeued/memory") else: request = self._dqpop() if request is not None: - self.stats.inc_value("scheduler/dequeued/disk", spider=self.spider) + self.stats.inc_value("scheduler/dequeued/disk") if request is not None: - self.stats.inc_value("scheduler/dequeued", spider=self.spider) + self.stats.inc_value("scheduler/dequeued") return request def __len__(self) -> int: @@ -402,7 +402,7 @@ class Scheduler(BaseScheduler): ) self.logunser = False assert self.stats is not None - self.stats.inc_value("scheduler/unserializable", spider=self.spider) + self.stats.inc_value("scheduler/unserializable") return False return True @@ -491,7 +491,7 @@ class Scheduler(BaseScheduler): if not path.exists(): return [] with path.open(encoding="utf-8") as f: - return cast(list[int], json.load(f)) + return cast("list[int]", json.load(f)) def _write_dqs_state(self, dqdir: str, state: list[int]) -> None: with Path(dqdir, "active.json").open("w", encoding="utf-8") as f: diff --git a/scrapy/core/scraper.py b/scrapy/core/scraper.py index 1f0d57c63..d29dc2c06 100644 --- a/scrapy/core/scraper.py +++ b/scrapy/core/scraper.py @@ -7,9 +7,9 @@ import logging import warnings from collections import deque from collections.abc import AsyncIterator -from typing import TYPE_CHECKING, Any, TypeVar, Union +from typing import TYPE_CHECKING, Any, TypeAlias, TypeVar -from twisted.internet.defer import Deferred, inlineCallbacks, maybeDeferred +from twisted.internet.defer import Deferred, inlineCallbacks from twisted.python.failure import Failure from scrapy import Spider, signals @@ -21,19 +21,24 @@ from scrapy.exceptions import ( ScrapyDeprecationWarning, ) from scrapy.http import Request, Response +from scrapy.pipelines import ItemPipelineManager from scrapy.utils.asyncio import _parallel_asyncio, is_asyncio_available +from scrapy.utils.decorators import _warn_spider_arg from scrapy.utils.defer import ( - _defer_sleep, + _defer_sleep_async, + _schedule_coro, aiter_errback, - deferred_f_from_coro_f, deferred_from_coro, + ensure_awaitable, iter_errback, maybe_deferred_to_future, parallel, parallel_async, ) +from scrapy.utils.deprecate import method_is_overridden from scrapy.utils.log import failure_to_exc_info, logformatter_adapter from scrapy.utils.misc import load_object, warn_on_generator_with_return_value +from scrapy.utils.python import global_object_name from scrapy.utils.spider import iterate_spider_output if TYPE_CHECKING: @@ -41,7 +46,6 @@ if TYPE_CHECKING: from scrapy.crawler import Crawler from scrapy.logformatter import LogFormatter - from scrapy.pipelines import ItemPipelineManager from scrapy.signalmanager import SignalManager @@ -49,7 +53,7 @@ logger = logging.getLogger(__name__) _T = TypeVar("_T") -QueueTuple = tuple[Union[Response, Failure], Request, Deferred[None]] +QueueTuple: TypeAlias = tuple[Response | Failure, Request, Deferred[None]] class Slot: @@ -106,33 +110,97 @@ class Scraper: crawler.settings["ITEM_PROCESSOR"] ) self.itemproc: ItemPipelineManager = itemproc_cls.from_crawler(crawler) + self._itemproc_has_async: dict[str, bool] = {} + for method in [ + "open_spider", + "close_spider", + "process_item", + ]: + self._check_deprecated_itemproc_method(method) + self.concurrent_items: int = crawler.settings.getint("CONCURRENT_ITEMS") self.crawler: Crawler = crawler self.signals: SignalManager = crawler.signals assert crawler.logformatter self.logformatter: LogFormatter = crawler.logformatter - @deferred_f_from_coro_f - async def open_spider(self, spider: Spider) -> None: - """Open the given spider for scraping and allocate resources for it""" - self.slot = Slot(self.crawler.settings.getint("SCRAPER_SLOT_MAX_ACTIVE_SIZE")) - await maybe_deferred_to_future(self.itemproc.open_spider(spider)) - - def close_spider(self, spider: Spider | None = None) -> Deferred[Spider]: - """Close a spider being scraped and release its resources""" - if spider is not None: + def _check_deprecated_itemproc_method(self, method: str) -> None: + itemproc_cls = type(self.itemproc) + if not hasattr(self.itemproc, "process_item_async"): warnings.warn( - "Passing a 'spider' argument to Scraper.close_spider() is deprecated.", - category=ScrapyDeprecationWarning, + f"{global_object_name(itemproc_cls)} doesn't define a {method}_async() method," + f" this is deprecated and the method will be required in future Scrapy versions.", + ScrapyDeprecationWarning, stacklevel=2, ) + self._itemproc_has_async[method] = False + elif ( + issubclass(itemproc_cls, ItemPipelineManager) + and method_is_overridden(itemproc_cls, ItemPipelineManager, method) + and not method_is_overridden( + itemproc_cls, ItemPipelineManager, f"{method}_async" + ) + ): + warnings.warn( + f"{global_object_name(itemproc_cls)} overrides {method}() but doesn't override {method}_async()." + f" This is deprecated. {method}() will be used, but in future Scrapy versions {method}_async() will be used instead.", + ScrapyDeprecationWarning, + stacklevel=2, + ) + self._itemproc_has_async[method] = False + else: + self._itemproc_has_async[method] = True + def open_spider(self, spider: Spider | None = None) -> Deferred[None]: + warnings.warn( + "Scraper.open_spider() is deprecated, use open_spider_async() instead", + ScrapyDeprecationWarning, + stacklevel=2, + ) + return deferred_from_coro(self.open_spider_async()) + + async def open_spider_async(self) -> None: + """Open the spider for scraping and allocate resources for it. + + .. versionadded:: VERSION + """ + self.slot = Slot(self.crawler.settings.getint("SCRAPER_SLOT_MAX_ACTIVE_SIZE")) + if not self.crawler.spider: + raise RuntimeError( + "Scraper.open_spider() called before Crawler.spider is set." + ) + if self._itemproc_has_async["open_spider"]: + await self.itemproc.open_spider_async() + else: + await maybe_deferred_to_future( + self.itemproc.open_spider(self.crawler.spider) + ) + + def close_spider(self, spider: Spider | None = None) -> Deferred[None]: + warnings.warn( + "Scraper.close_spider() is deprecated, use close_spider_async() instead", + ScrapyDeprecationWarning, + stacklevel=2, + ) + return deferred_from_coro(self.close_spider_async()) + + async def close_spider_async(self) -> None: + """Close the spider being scraped and release its resources. + + .. versionadded:: VERSION + """ if self.slot is None: raise RuntimeError("Scraper slot not assigned") self.slot.closing = Deferred() - self.slot.closing.addCallback(self.itemproc.close_spider) self._check_if_closing() - return self.slot.closing + await maybe_deferred_to_future(self.slot.closing) + if self._itemproc_has_async["close_spider"]: + await self.itemproc.close_spider_async() + else: + assert self.crawler.spider + await maybe_deferred_to_future( + self.itemproc.close_spider(self.crawler.spider) + ) def is_idle(self) -> bool: """Return True if there isn't any more spiders to process""" @@ -140,28 +208,21 @@ class Scraper: def _check_if_closing(self) -> None: assert self.slot is not None # typing - assert self.crawler.spider if self.slot.closing and self.slot.is_idle(): assert self.crawler.spider self.slot.closing.callback(self.crawler.spider) @inlineCallbacks + @_warn_spider_arg def enqueue_scrape( self, result: Response | Failure, request: Request, spider: Spider | None = None ) -> Generator[Deferred[Any], Any, None]: - if spider is not None: - warnings.warn( - "Passing a 'spider' argument to Scraper.enqueue_scrape() is deprecated.", - category=ScrapyDeprecationWarning, - stacklevel=2, - ) - if self.slot is None: raise RuntimeError("Scraper slot not assigned") dfd = self.slot.add_response_request(result, request) self._scrape_next() try: - yield dfd + yield dfd # fired in _wait_for_processing() except Exception: logger.error( "Scraper bug processing %(request)s", @@ -177,10 +238,9 @@ class Scraper: def _scrape_next(self) -> None: assert self.slot is not None # typing while self.slot.queue: - result, request, deferred = self.slot.next_response_request_deferred() - self._scrape(result, request).chainDeferred(deferred) + result, request, queue_dfd = self.slot.next_response_request_deferred() + _schedule_coro(self._wait_for_processing(result, request, queue_dfd)) - @deferred_f_from_coro_f async def _scrape(self, result: Response | Failure, request: Request) -> None: """Handle the downloaded response or failure through the spider callback/errback.""" if not isinstance(result, (Response, Failure)): @@ -188,13 +248,12 @@ class Scraper: f"Incorrect type: expected Response or Failure, got {type(result)}: {result!r}" ) - assert self.crawler.spider output: Iterable[Any] | AsyncIterator[Any] if isinstance(result, Response): try: # call the spider middlewares and the request callback with the response output = await self.spidermw.scrape_response_async( - self.call_spider, result, request, self.crawler.spider + self.call_spider_async, result, request ) except Exception: self.handle_spider_error(Failure(), request, result) @@ -207,6 +266,7 @@ class Scraper: output = await self.call_spider_async(result, request) except Exception as spider_exc: # the errback didn't silence the exception + assert self.crawler.spider if not result.check(IgnoreRequest): logkws = self.logformatter.download_error( result, request, self.crawler.spider @@ -222,22 +282,34 @@ class Scraper: else: await self.handle_spider_output_async(output, request, result) + async def _wait_for_processing( + self, result: Response | Failure, request: Request, queue_dfd: Deferred[None] + ) -> None: + try: + await self._scrape(result, request) + except Exception: + queue_dfd.errback(Failure()) + else: + queue_dfd.callback(None) # awaited in enqueue_scrape() + def call_spider( self, result: Response | Failure, request: Request, spider: Spider | None = None ) -> Deferred[Iterable[Any] | AsyncIterator[Any]]: - if spider is not None: - warnings.warn( - "Passing a 'spider' argument to Scraper.call_spider() is deprecated.", - category=ScrapyDeprecationWarning, - stacklevel=2, - ) + warnings.warn( + "Scraper.call_spider() is deprecated, use call_spider_async() instead", + ScrapyDeprecationWarning, + stacklevel=2, + ) return deferred_from_coro(self.call_spider_async(result, request)) async def call_spider_async( self, result: Response | Failure, request: Request ) -> Iterable[Any] | AsyncIterator[Any]: - """Call the request callback or errback with the response or failure.""" - await maybe_deferred_to_future(_defer_sleep()) + """Call the request callback or errback with the response or failure. + + .. versionadded:: 2.13 + """ + await _defer_sleep_async() assert self.crawler.spider if isinstance(result, Response): if getattr(result, "request", None) is None: @@ -257,10 +329,9 @@ class Scraper: output.raiseException() # else the errback returned actual output (like a callback), # which needs to be passed to iterate_spider_output() - return await maybe_deferred_to_future( - maybeDeferred(iterate_spider_output, output) - ) + return await ensure_awaitable(iterate_spider_output(output)) + @_warn_spider_arg def handle_spider_error( self, _failure: Failure, @@ -269,19 +340,12 @@ class Scraper: spider: Spider | None = None, ) -> None: """Handle an exception raised by a spider callback or errback.""" - if spider is not None: - warnings.warn( - "Passing a 'spider' argument to Scraper.handle_spider_error() is deprecated.", - category=ScrapyDeprecationWarning, - stacklevel=2, - ) - assert self.crawler.spider exc = _failure.value if isinstance(exc, CloseSpider): assert self.crawler.engine is not None # typing - self.crawler.engine.close_spider( - self.crawler.spider, exc.reason or "cancelled" + _schedule_coro( + self.crawler.engine.close_spider_async(reason=exc.reason or "cancelled") ) return logkws = self.logformatter.spider_error( @@ -299,12 +363,9 @@ class Scraper: spider=self.crawler.spider, ) assert self.crawler.stats + self.crawler.stats.inc_value("spider_exceptions/count") self.crawler.stats.inc_value( - "spider_exceptions/count", spider=self.crawler.spider - ) - self.crawler.stats.inc_value( - f"spider_exceptions/{_failure.value.__class__.__name__}", - spider=self.crawler.spider, + f"spider_exceptions/{_failure.value.__class__.__name__}" ) def handle_spider_output( @@ -315,12 +376,11 @@ class Scraper: spider: Spider | None = None, ) -> Deferred[None]: """Pass items/requests produced by a callback to ``_process_spidermw_output()`` in parallel.""" - if spider is not None: - warnings.warn( - "Passing a 'spider' argument to Scraper.handle_spider_output() is deprecated.", - category=ScrapyDeprecationWarning, - stacklevel=2, - ) + warnings.warn( + "Scraper.handle_spider_output() is deprecated, use handle_spider_output_async() instead", + ScrapyDeprecationWarning, + stacklevel=2, + ) return deferred_from_coro( self.handle_spider_output_async(result, request, response) ) @@ -331,7 +391,10 @@ class Scraper: request: Request, response: Response | Failure, ) -> None: - """Pass items/requests produced by a callback to ``_process_spidermw_output()`` in parallel.""" + """Pass items/requests produced by a callback to ``_process_spidermw_output()`` in parallel. + + .. versionadded:: 2.13 + """ it: Iterable[_T] | AsyncIterator[_T] if is_asyncio_available(): if isinstance(result, AsyncIterator): @@ -396,6 +459,11 @@ class Scraper: *response* is the source of the item data. If the item does not come from response data, e.g. it was hard-coded, set it to ``None``. """ + warnings.warn( + "Scraper.start_itemproc() is deprecated, use start_itemproc_async() instead", + ScrapyDeprecationWarning, + stacklevel=2, + ) return deferred_from_coro(self.start_itemproc_async(item, response=response)) async def start_itemproc_async( @@ -405,14 +473,19 @@ class Scraper: *response* is the source of the item data. If the item does not come from response data, e.g. it was hard-coded, set it to ``None``. + + .. versionadded:: VERSION """ assert self.slot is not None # typing assert self.crawler.spider is not None # typing self.slot.itemproc_size += 1 try: - output = await maybe_deferred_to_future( - self.itemproc.process_item(item, self.crawler.spider) - ) + if self._itemproc_has_async["process_item"]: + output = await self.itemproc.process_item_async(item) + else: + output = await maybe_deferred_to_future( + self.itemproc.process_item(item, self.crawler.spider) + ) except DropItem as ex: logkws = self.logformatter.dropped(item, ex, response, self.crawler.spider) if logkws is not None: diff --git a/scrapy/core/spidermw.py b/scrapy/core/spidermw.py index 10aad7858..6f694753a 100644 --- a/scrapy/core/spidermw.py +++ b/scrapy/core/spidermw.py @@ -7,10 +7,11 @@ See documentation in docs/topics/spider-middleware.rst from __future__ import annotations import logging -from collections.abc import AsyncIterator, Callable, Iterable +from collections.abc import AsyncIterator, Callable, Coroutine, Iterable +from functools import wraps from inspect import isasyncgenfunction, iscoroutine from itertools import islice -from typing import TYPE_CHECKING, Any, TypeVar, Union, cast +from typing import TYPE_CHECKING, Any, TypeAlias, TypeVar, cast from warnings import warn from twisted.internet.defer import Deferred, inlineCallbacks @@ -23,15 +24,16 @@ from scrapy.middleware import MiddlewareManager from scrapy.utils.asyncgen import as_async_generator, collect_asyncgen from scrapy.utils.conf import build_component_list from scrapy.utils.defer import ( + _defer_sleep_async, deferred_from_coro, maybe_deferred_to_future, - mustbe_deferred, ) from scrapy.utils.python import MutableAsyncChain, MutableChain, global_object_name if TYPE_CHECKING: from collections.abc import Generator + from scrapy.crawler import Crawler from scrapy.settings import BaseSettings @@ -39,9 +41,9 @@ logger = logging.getLogger(__name__) _T = TypeVar("_T") -ScrapeFunc = Callable[ - [Union[Response, Failure], Request], - Deferred[Union[Iterable[_T], AsyncIterator[_T]]], +ScrapeFunc: TypeAlias = Callable[ + [Response | Failure, Request], + Coroutine[Any, Any, Iterable[_T] | AsyncIterator[_T]], ] @@ -56,12 +58,12 @@ class SpiderMiddlewareManager(MiddlewareManager): def _get_mwlist_from_settings(cls, settings: BaseSettings) -> list[Any]: return build_component_list(settings.getwithbase("SPIDER_MIDDLEWARES")) - def __init__(self, *middlewares: Any) -> None: + def __init__(self, *middlewares: Any, crawler: Crawler | None = None) -> None: self._check_deprecated_process_start_requests_use(middlewares) - super().__init__(*middlewares) + super().__init__(*middlewares, crawler=crawler) def _check_deprecated_process_start_requests_use( - self, middlewares: tuple[Any] + self, middlewares: tuple[Any, ...] ) -> None: deprecated_middlewares = [ middleware @@ -115,9 +117,10 @@ class SpiderMiddlewareManager(MiddlewareManager): ) def _add_middleware(self, mw: Any) -> None: - super()._add_middleware(mw) if hasattr(mw, "process_spider_input"): self.methods["process_spider_input"].append(mw.process_spider_input) + self._check_mw_method_spider_arg(mw.process_spider_input) + if self._use_start_requests: if hasattr(mw, "process_start_requests"): self.methods["process_start_requests"].appendleft( @@ -125,22 +128,33 @@ class SpiderMiddlewareManager(MiddlewareManager): ) elif hasattr(mw, "process_start"): self.methods["process_start"].appendleft(mw.process_start) + process_spider_output = self._get_async_method_pair(mw, "process_spider_output") self.methods["process_spider_output"].appendleft(process_spider_output) + if callable(process_spider_output): + self._check_mw_method_spider_arg(process_spider_output) + elif isinstance(process_spider_output, tuple): + for m in process_spider_output: + self._check_mw_method_spider_arg(m) + process_spider_exception = getattr(mw, "process_spider_exception", None) self.methods["process_spider_exception"].appendleft(process_spider_exception) + if process_spider_exception is not None: + self._check_mw_method_spider_arg(process_spider_exception) - def _process_spider_input( + async def _process_spider_input( self, scrape_func: ScrapeFunc[_T], response: Response, request: Request, - spider: Spider, - ) -> Deferred[Iterable[_T] | AsyncIterator[_T]]: + ) -> Iterable[_T] | AsyncIterator[_T]: for method in self.methods["process_spider_input"]: - method = cast(Callable, method) + method = cast("Callable", method) try: - result = method(response=response, spider=spider) + if method in self._mw_methods_requiring_spider: + result = method(response=response, spider=self._spider) + else: + result = method(response=response) if result is not None: msg = ( f"{global_object_name(method)} must return None " @@ -150,13 +164,12 @@ class SpiderMiddlewareManager(MiddlewareManager): except _InvalidOutput: raise except Exception: - return scrape_func(Failure(), request) - return scrape_func(response, request) + return await scrape_func(Failure(), request) + return await scrape_func(response, request) def _evaluate_iterable( self, response: Response, - spider: Spider, iterable: Iterable[_T] | AsyncIterator[_T], exception_processor_index: int, recover_to: MutableChain[_T] | MutableAsyncChain[_T], @@ -166,9 +179,9 @@ class SpiderMiddlewareManager(MiddlewareManager): yield from iterable except Exception as ex: exception_result = cast( - Union[Failure, MutableChain[_T]], + "Failure | MutableChain[_T]", self._process_spider_exception( - response, spider, ex, exception_processor_index + response, ex, exception_processor_index ), ) if isinstance(exception_result, Failure): @@ -182,9 +195,9 @@ class SpiderMiddlewareManager(MiddlewareManager): yield r except Exception as ex: exception_result = cast( - Union[Failure, MutableAsyncChain[_T]], + "Failure | MutableAsyncChain[_T]", self._process_spider_exception( - response, spider, ex, exception_processor_index + response, ex, exception_processor_index ), ) if isinstance(exception_result, Failure): @@ -199,7 +212,6 @@ class SpiderMiddlewareManager(MiddlewareManager): def _process_spider_exception( self, response: Response, - spider: Spider, exception: Exception, start_index: int = 0, ) -> MutableChain[_T] | MutableAsyncChain[_T]: @@ -212,23 +224,24 @@ class SpiderMiddlewareManager(MiddlewareManager): for method_index, method in enumerate(method_list, start=start_index): if method is None: continue - method = cast(Callable, method) - result = method(response=response, exception=exception, spider=spider) + method = cast("Callable", method) + if method in self._mw_methods_requiring_spider: + result = method( + response=response, exception=exception, spider=self._spider + ) + else: + result = method(response=response, exception=exception) if _isiterable(result): # stop exception handling by handing control over to the # process_spider_output chain if an iterable has been returned dfd: Deferred[MutableChain[_T] | MutableAsyncChain[_T]] = ( - self._process_spider_output( - response, spider, result, method_index + 1 - ) + self._process_spider_output(response, result, method_index + 1) ) # _process_spider_output() returns a Deferred only because of downgrading so this can be # simplified when downgrading is removed. if dfd.called: # the result is available immediately if _process_spider_output didn't do downgrading - return cast( - Union[MutableChain[_T], MutableAsyncChain[_T]], dfd.result - ) + return cast("MutableChain[_T] | MutableAsyncChain[_T]", dfd.result) # we forbid waiting here because otherwise we would need to return a deferred from # _process_spider_exception too, which complicates the architecture msg = f"Async iterable returned from {global_object_name(method)} cannot be downgraded" @@ -249,7 +262,6 @@ class SpiderMiddlewareManager(MiddlewareManager): def _process_spider_output( self, response: Response, - spider: Spider, result: Iterable[_T] | AsyncIterator[_T], start_index: int = 0, ) -> Generator[Deferred[Any], Any, MutableChain[_T] | MutableAsyncChain[_T]]: @@ -302,19 +314,22 @@ class SpiderMiddlewareManager(MiddlewareManager): ) recovered = MutableChain(recovered_collected) # might fail directly if the output value is not a generator - result = method(response=response, result=result, spider=spider) + if method in self._mw_methods_requiring_spider: + result = method( + response=response, result=result, spider=self._spider + ) + else: + result = method(response=response, result=result) except Exception as ex: exception_result: Failure | MutableChain[_T] | MutableAsyncChain[_T] = ( - self._process_spider_exception( - response, spider, ex, method_index + 1 - ) + self._process_spider_exception(response, ex, method_index + 1) ) if isinstance(exception_result, Failure): raise return exception_result if _isiterable(result): result = self._evaluate_iterable( - response, spider, result, method_index + 1, recovered + response, result, method_index + 1, recovered ) else: if iscoroutine(result): @@ -338,7 +353,6 @@ class SpiderMiddlewareManager(MiddlewareManager): async def _process_callback_output( self, response: Response, - spider: Spider, result: Iterable[_T] | AsyncIterator[_T], ) -> MutableChain[_T] | MutableAsyncChain[_T]: recovered: MutableChain[_T] | MutableAsyncChain[_T] @@ -346,11 +360,11 @@ class SpiderMiddlewareManager(MiddlewareManager): recovered = MutableAsyncChain() else: recovered = MutableChain() - result = self._evaluate_iterable(response, spider, result, 0, recovered) + result = self._evaluate_iterable(response, result, 0, recovered) result = await maybe_deferred_to_future( cast( "Deferred[Iterable[_T] | AsyncIterator[_T]]", - self._process_spider_output(response, spider, result), + self._process_spider_output(response, result), ) ) if isinstance(result, AsyncIterator): @@ -362,13 +376,29 @@ class SpiderMiddlewareManager(MiddlewareManager): def scrape_response( self, - scrape_func: ScrapeFunc[_T], + scrape_func: Callable[ + [Response | Failure, Request], + Deferred[Iterable[_T] | AsyncIterator[_T]], + ], response: Response, request: Request, spider: Spider, ) -> Deferred[MutableChain[_T] | MutableAsyncChain[_T]]: + warn( + "SpiderMiddlewareManager.scrape_response() is deprecated, use scrape_response_async() instead", + ScrapyDeprecationWarning, + stacklevel=2, + ) + + @wraps(scrape_func) + async def scrape_func_wrapped( + response: Response | Failure, request: Request + ) -> Iterable[_T] | AsyncIterator[_T]: + return await maybe_deferred_to_future(scrape_func(response, request)) + + self._set_compat_spider(spider) return deferred_from_coro( - self.scrape_response_async(scrape_func, response, request, spider) + self.scrape_response_async(scrape_func_wrapped, response, request) ) async def scrape_response_async( @@ -376,47 +406,54 @@ class SpiderMiddlewareManager(MiddlewareManager): scrape_func: ScrapeFunc[_T], response: Response, request: Request, - spider: Spider, ) -> MutableChain[_T] | MutableAsyncChain[_T]: + if not self.crawler: + raise RuntimeError( + "scrape_response_async() called on a SpiderMiddlewareManager" + " instance created without a crawler." + ) + async def process_callback_output( result: Iterable[_T] | AsyncIterator[_T], ) -> MutableChain[_T] | MutableAsyncChain[_T]: - return await self._process_callback_output(response, spider, result) + return await self._process_callback_output(response, result) def process_spider_exception( exception: Exception, ) -> MutableChain[_T] | MutableAsyncChain[_T]: - return self._process_spider_exception(response, spider, exception) + return self._process_spider_exception(response, exception) try: - it: Iterable[_T] | AsyncIterator[_T] = await maybe_deferred_to_future( - mustbe_deferred( - self._process_spider_input, scrape_func, response, request, spider - ) + it: Iterable[_T] | AsyncIterator[_T] = await self._process_spider_input( + scrape_func, response, request ) return await process_callback_output(it) except Exception as ex: + await _defer_sleep_async() return process_spider_exception(ex) - async def process_start(self, spider: Spider) -> AsyncIterator[Any] | None: - self._check_deprecated_start_requests_use(spider) + async def process_start( + self, spider: Spider | None = None + ) -> AsyncIterator[Any] | None: + if spider: + self._warn_spider_arg("process_start") + self._set_compat_spider(spider) + self._check_deprecated_start_requests_use() if self._use_start_requests: - sync_start = iter(spider.start_requests()) - sync_start = await maybe_deferred_to_future( - self._process_chain("process_start_requests", sync_start, spider) + sync_start = iter(self._spider.start_requests()) + sync_start = await self._process_chain( + "process_start_requests", sync_start, always_add_spider=True ) start: AsyncIterator[Any] = as_async_generator(sync_start) else: - start = spider.start() - start = await maybe_deferred_to_future( - self._process_chain("process_start", start) - ) + start = self._spider.start() + start = await self._process_chain("process_start", start) return start - def _check_deprecated_start_requests_use(self, spider: Spider): + def _check_deprecated_start_requests_use(self): start_requests_cls = None start_cls = None - spidercls = spider.__class__ + spidercls = self._spider.__class__ mro = spidercls.__mro__ for cls in mro: diff --git a/scrapy/crawler.py b/scrapy/crawler.py index d6fb9972e..ffbebe152 100644 --- a/scrapy/crawler.py +++ b/scrapy/crawler.py @@ -5,26 +5,23 @@ import contextlib import logging import pprint import signal +import warnings from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Any, TypeVar -from twisted.internet.defer import ( - Deferred, - DeferredList, - inlineCallbacks, -) +from twisted.internet.defer import Deferred, DeferredList, inlineCallbacks -from scrapy import Spider, signals +from scrapy import Spider from scrapy.addons import AddonManager from scrapy.core.engine import ExecutionEngine +from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.extension import ExtensionManager from scrapy.settings import Settings, overridden_settings from scrapy.signalmanager import SignalManager from scrapy.spiderloader import SpiderLoaderProtocol, get_spider_loader from scrapy.utils.asyncio import is_asyncio_available -from scrapy.utils.defer import deferred_from_coro, deferred_to_future +from scrapy.utils.defer import deferred_from_coro from scrapy.utils.log import ( - LogCounterHandler, configure_logging, get_scrapy_root_handler, install_scrapy_root_handler, @@ -99,13 +96,6 @@ class Crawler: self.addons.load_settings(self.settings) self.stats = load_object(self.settings["STATS_CLASS"])(self) - handler = LogCounterHandler(self, level=self.settings.get("LOG_LEVEL")) - logging.root.addHandler(handler) - # lambda is assigned to Crawler attribute because this way it is not - # garbage collected after leaving the scope - self.__remove_handler = lambda: logging.root.removeHandler(handler) - self.signals.connect(self.__remove_handler, signals.engine_stopped) - lf_cls: type[LogFormatter] = load_object(self.settings["LOG_FORMATTER"]) self.logformatter = lf_cls.from_crawler(self) @@ -163,12 +153,12 @@ class Crawler: self._apply_settings() self._update_root_log_handler() self.engine = self._create_engine() - yield self.engine.open_spider(self.spider) - yield self.engine.start() + yield deferred_from_coro(self.engine.open_spider_async()) + yield deferred_from_coro(self.engine.start_async()) except Exception: self.crawling = False if self.engine is not None: - yield self.engine.close() + yield deferred_from_coro(self.engine.close_async()) raise async def crawl_async(self, *args: Any, **kwargs: Any) -> None: @@ -199,41 +189,40 @@ class Crawler: self._apply_settings() self._update_root_log_handler() self.engine = self._create_engine() - await self.engine.open_spider_async(self.spider) + await self.engine.open_spider_async() await self.engine.start_async() except Exception: self.crawling = False if self.engine is not None: - await deferred_to_future(self.engine.close()) + await self.engine.close_async() raise def _create_spider(self, *args: Any, **kwargs: Any) -> Spider: return self.spidercls.from_crawler(self, *args, **kwargs) def _create_engine(self) -> ExecutionEngine: - return ExecutionEngine(self, lambda _: self.stop()) + return ExecutionEngine(self, lambda _: self.stop_async()) - @inlineCallbacks - def stop(self) -> Generator[Deferred[Any], Any, None]: + def stop(self) -> Deferred[None]: """Start a graceful stop of the crawler and return a deferred that is fired when the crawler is stopped.""" - if self.crawling: - self.crawling = False - assert self.engine - yield self.engine.stop() + warnings.warn( + "Crawler.stop() is deprecated, use stop_async() instead", + ScrapyDeprecationWarning, + stacklevel=2, + ) + return deferred_from_coro(self.stop_async()) async def stop_async(self) -> None: """Start a graceful stop of the crawler and complete when the crawler is stopped. .. versionadded:: VERSION - - This function requires - :class:`~twisted.internet.asyncioreactor.AsyncioSelectorReactor` to be - installed. """ - if not is_asyncio_available(): - raise RuntimeError("Crawler.stop_async() requires AsyncioSelectorReactor.") - await deferred_to_future(self.stop()) + if self.crawling: + self.crawling = False + assert self.engine + if self.engine.running: + await self.engine.stop_async() @staticmethod def _get_component( @@ -450,7 +439,7 @@ class CrawlerRunner(CrawlerRunnerBase): Returns a deferred that is fired when they all have ended. """ - return DeferredList(c.stop() for c in self.crawlers) + return DeferredList(deferred_from_coro(c.stop_async()) for c in self.crawlers) @inlineCallbacks def join(self) -> Generator[Deferred[Any], Any, None]: @@ -666,6 +655,7 @@ class CrawlerProcess(CrawlerProcessBase, CrawlerRunner): ): super().__init__(settings, install_root_handler) self._initialized_reactor: bool = False + logger.debug("Using CrawlerProcess") def _create_crawler(self, spidercls: type[Spider] | str) -> Crawler: if isinstance(spidercls, str): @@ -740,6 +730,7 @@ class AsyncCrawlerProcess(CrawlerProcessBase, AsyncCrawlerRunner): install_root_handler: bool = True, ): super().__init__(settings, install_root_handler) + logger.debug("Using AsyncCrawlerProcess") # We want the asyncio event loop to be installed early, so that it's # always the correct one. And as we do that, we can also install the # reactor here. diff --git a/scrapy/downloadermiddlewares/cookies.py b/scrapy/downloadermiddlewares/cookies.py index 9156b8c3a..d945546d5 100644 --- a/scrapy/downloadermiddlewares/cookies.py +++ b/scrapy/downloadermiddlewares/cookies.py @@ -9,6 +9,7 @@ from tldextract import TLDExtract from scrapy.exceptions import NotConfigured from scrapy.http import Response from scrapy.http.cookies import CookieJar +from scrapy.utils.decorators import _warn_spider_arg from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.python import to_unicode @@ -39,6 +40,8 @@ def _is_public_domain(domain: str) -> bool: class CookiesMiddleware: """This middleware enables working with sites that need cookies""" + crawler: Crawler + def __init__(self, debug: bool = False): self.jars: defaultdict[Any, CookieJar] = defaultdict(CookieJar) self.debug: bool = debug @@ -47,7 +50,9 @@ class CookiesMiddleware: def from_crawler(cls, crawler: Crawler) -> Self: if not crawler.settings.getbool("COOKIES_ENABLED"): raise NotConfigured - return cls(crawler.settings.getbool("COOKIES_DEBUG")) + o = cls(crawler.settings.getbool("COOKIES_DEBUG")) + o.crawler = crawler + return o def _process_cookies( self, cookies: Iterable[Cookie], *, jar: CookieJar, request: Request @@ -67,8 +72,9 @@ class CookiesMiddleware: jar.set_cookie_if_ok(cookie, request) + @_warn_spider_arg def process_request( - self, request: Request, spider: Spider + self, request: Request, spider: Spider | None = None ) -> Request | Response | None: if request.meta.get("dont_merge_cookies", False): return None @@ -81,11 +87,12 @@ class CookiesMiddleware: # set Cookie header request.headers.pop("Cookie", None) jar.add_cookie_header(request) - self._debug_cookie(request, spider) + self._debug_cookie(request) return None + @_warn_spider_arg def process_response( - self, request: Request, response: Response, spider: Spider + self, request: Request, response: Response, spider: Spider | None = None ) -> Request | Response: if request.meta.get("dont_merge_cookies", False): return response @@ -96,11 +103,11 @@ class CookiesMiddleware: cookies = jar.make_cookies(response, request) self._process_cookies(cookies, jar=jar, request=request) - self._debug_set_cookie(response, spider) + self._debug_set_cookie(response) return response - def _debug_cookie(self, request: Request, spider: Spider) -> None: + def _debug_cookie(self, request: Request) -> None: if self.debug: cl = [ to_unicode(c, errors="replace") @@ -109,9 +116,9 @@ class CookiesMiddleware: if cl: cookies = "\n".join(f"Cookie: {c}\n" for c in cl) msg = f"Sending cookies to: {request}\n{cookies}" - logger.debug(msg, extra={"spider": spider}) + logger.debug(msg, extra={"spider": self.crawler.spider}) - def _debug_set_cookie(self, response: Response, spider: Spider) -> None: + def _debug_set_cookie(self, response: Response) -> None: if self.debug: cl = [ to_unicode(c, errors="replace") @@ -120,7 +127,7 @@ class CookiesMiddleware: if cl: cookies = "\n".join(f"Set-Cookie: {c}\n" for c in cl) msg = f"Received cookies from: {response}\n{cookies}" - logger.debug(msg, extra={"spider": spider}) + logger.debug(msg, extra={"spider": self.crawler.spider}) def _format_cookie(self, cookie: VerboseCookie, request: Request) -> str | None: """ diff --git a/scrapy/downloadermiddlewares/defaultheaders.py b/scrapy/downloadermiddlewares/defaultheaders.py index d58b4490b..7c940a078 100644 --- a/scrapy/downloadermiddlewares/defaultheaders.py +++ b/scrapy/downloadermiddlewares/defaultheaders.py @@ -8,6 +8,7 @@ from __future__ import annotations from typing import TYPE_CHECKING +from scrapy.utils.decorators import _warn_spider_arg from scrapy.utils.python import without_none_values if TYPE_CHECKING: @@ -30,8 +31,9 @@ class DefaultHeadersMiddleware: headers = without_none_values(crawler.settings["DEFAULT_REQUEST_HEADERS"]) return cls(headers.items()) + @_warn_spider_arg def process_request( - self, request: Request, spider: Spider + self, request: Request, spider: Spider | None = None ) -> Request | Response | None: for k, v in self._headers: request.headers.setdefault(k, v) diff --git a/scrapy/downloadermiddlewares/downloadtimeout.py b/scrapy/downloadermiddlewares/downloadtimeout.py index 28456c697..b57d5c2a9 100644 --- a/scrapy/downloadermiddlewares/downloadtimeout.py +++ b/scrapy/downloadermiddlewares/downloadtimeout.py @@ -9,6 +9,7 @@ from __future__ import annotations from typing import TYPE_CHECKING from scrapy import Request, Spider, signals +from scrapy.utils.decorators import _warn_spider_arg if TYPE_CHECKING: # typing.Self requires Python 3.11 @@ -31,8 +32,9 @@ class DownloadTimeoutMiddleware: def spider_opened(self, spider: Spider) -> None: self._timeout = getattr(spider, "download_timeout", self._timeout) + @_warn_spider_arg def process_request( - self, request: Request, spider: Spider + self, request: Request, spider: Spider | None = None ) -> Request | Response | None: if self._timeout: request.meta.setdefault("download_timeout", self._timeout) diff --git a/scrapy/downloadermiddlewares/httpauth.py b/scrapy/downloadermiddlewares/httpauth.py index 80107261b..c28c93d4e 100644 --- a/scrapy/downloadermiddlewares/httpauth.py +++ b/scrapy/downloadermiddlewares/httpauth.py @@ -11,6 +11,7 @@ from typing import TYPE_CHECKING from w3lib.http import basic_auth_header from scrapy import Request, Spider, signals +from scrapy.utils.decorators import _warn_spider_arg from scrapy.utils.url import url_is_from_any_domain if TYPE_CHECKING: @@ -38,8 +39,9 @@ class HttpAuthMiddleware: self.auth = basic_auth_header(usr, pwd) self.domain = spider.http_auth_domain # type: ignore[attr-defined] + @_warn_spider_arg def process_request( - self, request: Request, spider: Spider + self, request: Request, spider: Spider | None = None ) -> Request | Response | None: auth = getattr(self, "auth", None) if ( diff --git a/scrapy/downloadermiddlewares/httpcache.py b/scrapy/downloadermiddlewares/httpcache.py index 3892dba23..453e8a6a3 100644 --- a/scrapy/downloadermiddlewares/httpcache.py +++ b/scrapy/downloadermiddlewares/httpcache.py @@ -8,15 +8,16 @@ from twisted.internet.error import ( ConnectError, ConnectionDone, ConnectionLost, - ConnectionRefusedError, DNSLookupError, TCPTimedOutError, - TimeoutError, ) +from twisted.internet.error import ConnectionRefusedError as TxConnectionRefusedError +from twisted.internet.error import TimeoutError as TxTimeoutError from twisted.web.client import ResponseFailed from scrapy import signals from scrapy.exceptions import IgnoreRequest, NotConfigured +from scrapy.utils.decorators import _warn_spider_arg from scrapy.utils.misc import load_object if TYPE_CHECKING: @@ -34,9 +35,9 @@ if TYPE_CHECKING: class HttpCacheMiddleware: DOWNLOAD_EXCEPTIONS = ( defer.TimeoutError, - TimeoutError, + TxTimeoutError, DNSLookupError, - ConnectionRefusedError, + TxConnectionRefusedError, ConnectionDone, ConnectError, ConnectionLost, @@ -45,6 +46,8 @@ class HttpCacheMiddleware: OSError, ) + crawler: Crawler + def __init__(self, settings: Settings, stats: StatsCollector) -> None: if not settings.getbool("HTTPCACHE_ENABLED"): raise NotConfigured @@ -59,6 +62,7 @@ class HttpCacheMiddleware: o = cls(crawler.settings, crawler.stats) crawler.signals.connect(o.spider_opened, signal=signals.spider_opened) crawler.signals.connect(o.spider_closed, signal=signals.spider_closed) + o.crawler = crawler return o def spider_opened(self, spider: Spider) -> None: @@ -67,8 +71,9 @@ class HttpCacheMiddleware: def spider_closed(self, spider: Spider) -> None: self.storage.close_spider(spider) + @_warn_spider_arg def process_request( - self, request: Request, spider: Spider + self, request: Request, spider: Spider | None = None ) -> Request | Response | None: if request.meta.get("dont_cache", False): return None @@ -80,19 +85,19 @@ class HttpCacheMiddleware: # Look for cached response and check if expired cachedresponse: Response | None = self.storage.retrieve_response( - spider, request + self.crawler.spider, request ) if cachedresponse is None: - self.stats.inc_value("httpcache/miss", spider=spider) + self.stats.inc_value("httpcache/miss") if self.ignore_missing: - self.stats.inc_value("httpcache/ignore", spider=spider) + self.stats.inc_value("httpcache/ignore") raise IgnoreRequest(f"Ignored request not in cache: {request}") return None # first time request # Return cached response only if not expired cachedresponse.flags.append("cached") if self.policy.is_cached_response_fresh(cachedresponse, request): - self.stats.inc_value("httpcache/hit", spider=spider) + self.stats.inc_value("httpcache/hit") return cachedresponse # Keep a reference to cached response to avoid a second cache lookup on @@ -101,8 +106,9 @@ class HttpCacheMiddleware: return None + @_warn_spider_arg def process_response( - self, request: Request, response: Response, spider: Spider + self, request: Request, response: Response, spider: Spider | None = None ) -> Request | Response: if request.meta.get("dont_cache", False): return response @@ -120,38 +126,33 @@ class HttpCacheMiddleware: # Do not validate first-hand responses cachedresponse: Response | None = request.meta.pop("cached_response", None) if cachedresponse is None: - self.stats.inc_value("httpcache/firsthand", spider=spider) - self._cache_response(spider, response, request, cachedresponse) + self.stats.inc_value("httpcache/firsthand") + self._cache_response(response, request) return response if self.policy.is_cached_response_valid(cachedresponse, response, request): - self.stats.inc_value("httpcache/revalidate", spider=spider) + self.stats.inc_value("httpcache/revalidate") return cachedresponse - self.stats.inc_value("httpcache/invalidate", spider=spider) - self._cache_response(spider, response, request, cachedresponse) + self.stats.inc_value("httpcache/invalidate") + self._cache_response(response, request) return response + @_warn_spider_arg def process_exception( - self, request: Request, exception: Exception, spider: Spider + self, request: Request, exception: Exception, spider: Spider | None = None ) -> Request | Response | None: cachedresponse: Response | None = request.meta.pop("cached_response", None) if cachedresponse is not None and isinstance( exception, self.DOWNLOAD_EXCEPTIONS ): - self.stats.inc_value("httpcache/errorrecovery", spider=spider) + self.stats.inc_value("httpcache/errorrecovery") return cachedresponse return None - def _cache_response( - self, - spider: Spider, - response: Response, - request: Request, - cachedresponse: Response | None, - ) -> None: + def _cache_response(self, response: Response, request: Request) -> None: if self.policy.should_cache_response(response, request): - self.stats.inc_value("httpcache/store", spider=spider) - self.storage.store_response(spider, request, response) + self.stats.inc_value("httpcache/store") + self.storage.store_response(self.crawler.spider, request, response) else: - self.stats.inc_value("httpcache/uncacheable", spider=spider) + self.stats.inc_value("httpcache/uncacheable") diff --git a/scrapy/downloadermiddlewares/httpcompression.py b/scrapy/downloadermiddlewares/httpcompression.py index 58891b952..e81888d9b 100644 --- a/scrapy/downloadermiddlewares/httpcompression.py +++ b/scrapy/downloadermiddlewares/httpcompression.py @@ -1,11 +1,12 @@ from __future__ import annotations +import warnings from itertools import chain from logging import getLogger from typing import TYPE_CHECKING, Any from scrapy import Request, Spider, signals -from scrapy.exceptions import IgnoreRequest, NotConfigured +from scrapy.exceptions import IgnoreRequest, NotConfigured, ScrapyDeprecationWarning from scrapy.http import Response, TextResponse from scrapy.responsetypes import responsetypes from scrapy.utils._compression import ( @@ -14,6 +15,7 @@ from scrapy.utils._compression import ( _unbrotli, _unzstd, ) +from scrapy.utils.decorators import _warn_spider_arg from scrapy.utils.gz import gunzip if TYPE_CHECKING: @@ -30,13 +32,22 @@ ACCEPTED_ENCODINGS: list[bytes] = [b"gzip", b"deflate"] try: try: - import brotli # noqa: F401 + import brotli except ImportError: - import brotlicffi # noqa: F401 + import brotlicffi as brotli except ImportError: pass else: - ACCEPTED_ENCODINGS.append(b"br") + try: + brotli.Decompressor.can_accept_more_data + except AttributeError: # pragma: no cover + warnings.warn( + "You have brotli installed. But 'br' encoding support now requires " + "brotli's or brotlicffi's version >= 1.2.0. Please upgrade " + "brotli/brotlicffi to make Scrapy decode 'br' encoded responses.", + ) + else: + ACCEPTED_ENCODINGS.append(b"br") try: import zstandard # noqa: F401 @@ -74,18 +85,34 @@ class HttpCompressionMiddleware: def open_spider(self, spider: Spider) -> None: if hasattr(spider, "download_maxsize"): + warnings.warn( + "The 'download_maxsize' spider attribute is deprecated. " + "Use Spider.custom_settings or Spider.update_settings() instead. " + "The corresponding setting name is 'DOWNLOAD_MAXSIZE'.", + category=ScrapyDeprecationWarning, + stacklevel=2, + ) self._max_size = spider.download_maxsize if hasattr(spider, "download_warnsize"): + warnings.warn( + "The 'download_warnsize' spider attribute is deprecated. " + "Use Spider.custom_settings or Spider.update_settings() instead. " + "The corresponding setting name is 'DOWNLOAD_WARNSIZE'.", + category=ScrapyDeprecationWarning, + stacklevel=2, + ) self._warn_size = spider.download_warnsize + @_warn_spider_arg def process_request( - self, request: Request, spider: Spider + self, request: Request, spider: Spider | None = None ) -> Request | Response | None: request.headers.setdefault("Accept-Encoding", b", ".join(ACCEPTED_ENCODINGS)) return None + @_warn_spider_arg def process_response( - self, request: Request, response: Response, spider: Spider + self, request: Request, response: Response, spider: Spider | None = None ) -> Request | Response: if request.method == "HEAD": return response @@ -98,13 +125,13 @@ class HttpCompressionMiddleware: decoded_body, content_encoding = self._handle_encoding( response.body, content_encoding, max_size ) - except _DecompressionMaxSizeExceeded: + except _DecompressionMaxSizeExceeded as e: raise IgnoreRequest( f"Ignored response {response} because its body " - f"({len(response.body)} B compressed) exceeded " - f"DOWNLOAD_MAXSIZE ({max_size} B) during " - f"decompression." - ) + f"({len(response.body)} B compressed, " + f"{e.decompressed_size} B decompressed so far) exceeded " + f"DOWNLOAD_MAXSIZE ({max_size} B) during decompression." + ) from e if len(response.body) < warn_size <= len(decoded_body): logger.warning( f"{response} body size after decompression " @@ -118,11 +145,8 @@ class HttpCompressionMiddleware: self.stats.inc_value( "httpcompression/response_bytes", len(decoded_body), - spider=spider, - ) - self.stats.inc_value( - "httpcompression/response_count", spider=spider ) + self.stats.inc_value("httpcompression/response_count") respcls = responsetypes.from_args( headers=response.headers, url=response.url, body=decoded_body ) @@ -187,7 +211,7 @@ class HttpCompressionMiddleware: f"from unsupported encoding(s) '{encodings_str}'." ) if b"br" in encodings: - msg += " You need to install brotli or brotlicffi to decode 'br'." + msg += " You need to install brotli or brotlicffi >= 1.2.0 to decode 'br'." if b"zstd" in encodings: msg += " You need to install zstandard to decode 'zstd'." logger.warning(msg) diff --git a/scrapy/downloadermiddlewares/httpproxy.py b/scrapy/downloadermiddlewares/httpproxy.py index cb7fa8c90..d3d46a947 100644 --- a/scrapy/downloadermiddlewares/httpproxy.py +++ b/scrapy/downloadermiddlewares/httpproxy.py @@ -10,6 +10,7 @@ from urllib.request import ( # type: ignore[attr-defined] ) from scrapy.exceptions import NotConfigured +from scrapy.utils.decorators import _warn_spider_arg from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.python import to_bytes @@ -55,8 +56,9 @@ class HttpProxyMiddleware: return creds, proxy_url + @_warn_spider_arg def process_request( - self, request: Request, spider: Spider + self, request: Request, spider: Spider | None = None ) -> Request | Response | None: creds, proxy_url, scheme = None, None, None if "proxy" in request.meta: diff --git a/scrapy/downloadermiddlewares/offsite.py b/scrapy/downloadermiddlewares/offsite.py index 787c46a60..33d7ba609 100644 --- a/scrapy/downloadermiddlewares/offsite.py +++ b/scrapy/downloadermiddlewares/offsite.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING from scrapy import Request, Spider, signals from scrapy.exceptions import IgnoreRequest +from scrapy.utils.decorators import _warn_spider_arg from scrapy.utils.httpobj import urlparse_cached if TYPE_CHECKING: @@ -21,29 +22,34 @@ logger = logging.getLogger(__name__) class OffsiteMiddleware: + crawler: Crawler + + def __init__(self, stats: StatsCollector): + self.stats = stats + self.domains_seen: set[str] = set() + @classmethod def from_crawler(cls, crawler: Crawler) -> Self: assert crawler.stats o = cls(crawler.stats) crawler.signals.connect(o.spider_opened, signal=signals.spider_opened) crawler.signals.connect(o.request_scheduled, signal=signals.request_scheduled) + o.crawler = crawler return o - def __init__(self, stats: StatsCollector): - self.stats = stats - self.domains_seen: set[str] = set() - def spider_opened(self, spider: Spider) -> None: self.host_regex: re.Pattern[str] = self.get_host_regex(spider) def request_scheduled(self, request: Request, spider: Spider) -> None: - self.process_request(request, spider) + self.process_request(request) - def process_request(self, request: Request, spider: Spider) -> None: + @_warn_spider_arg + def process_request(self, request: Request, spider: Spider | None = None) -> None: + assert self.crawler.spider if ( request.dont_filter or request.meta.get("allow_offsite") - or self.should_follow(request, spider) + or self.should_follow(request, self.crawler.spider) ): return domain = urlparse_cached(request).hostname @@ -52,10 +58,10 @@ class OffsiteMiddleware: logger.debug( "Filtered offsite request to %(domain)r: %(request)s", {"domain": domain, "request": request}, - extra={"spider": spider}, + extra={"spider": self.crawler.spider}, ) - self.stats.inc_value("offsite/domains", spider=spider) - self.stats.inc_value("offsite/filtered", spider=spider) + self.stats.inc_value("offsite/domains") + self.stats.inc_value("offsite/filtered") raise IgnoreRequest def should_follow(self, request: Request, spider: Spider) -> bool: diff --git a/scrapy/downloadermiddlewares/redirect.py b/scrapy/downloadermiddlewares/redirect.py index 612426371..61f62ecfc 100644 --- a/scrapy/downloadermiddlewares/redirect.py +++ b/scrapy/downloadermiddlewares/redirect.py @@ -8,6 +8,7 @@ from w3lib.url import safe_url_string from scrapy.exceptions import IgnoreRequest, NotConfigured from scrapy.http import HtmlResponse, Response +from scrapy.utils.decorators import _warn_spider_arg from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.response import get_meta_refresh @@ -79,6 +80,7 @@ def _build_redirect_request( class BaseRedirectMiddleware: + crawler: Crawler enabled_setting: str = "REDIRECT_ENABLED" def __init__(self, settings: BaseSettings): @@ -90,11 +92,11 @@ class BaseRedirectMiddleware: @classmethod def from_crawler(cls, crawler: Crawler) -> Self: - return cls(crawler.settings) + o = cls(crawler.settings) + o.crawler = crawler + return o - def _redirect( - self, redirected: Request, request: Request, spider: Spider, reason: Any - ) -> Request: + def _redirect(self, redirected: Request, request: Request, reason: Any) -> Request: ttl = request.meta.setdefault("redirect_ttl", self.max_redirect_times) redirects = request.meta.get("redirect_times", 0) + 1 @@ -114,13 +116,13 @@ class BaseRedirectMiddleware: logger.debug( "Redirecting (%(reason)s) to %(redirected)s from %(request)s", {"reason": reason, "redirected": redirected, "request": request}, - extra={"spider": spider}, + extra={"spider": self.crawler.spider}, ) return redirected logger.debug( "Discarding %(request)s: max redirections reached", {"request": request}, - extra={"spider": spider}, + extra={"spider": self.crawler.spider}, ) raise IgnoreRequest("max redirections reached") @@ -144,12 +146,14 @@ class RedirectMiddleware(BaseRedirectMiddleware): and meta-refresh html tag. """ + @_warn_spider_arg def process_response( - self, request: Request, response: Response, spider: Spider + self, request: Request, response: Response, spider: Spider | None = None ) -> Request | Response: if ( request.meta.get("dont_redirect", False) - or response.status in getattr(spider, "handle_httpstatus_list", []) + or response.status + in getattr(self.crawler.spider, "handle_httpstatus_list", []) or response.status in request.meta.get("handle_httpstatus_list", []) or request.meta.get("handle_httpstatus_all", False) ): @@ -171,10 +175,10 @@ class RedirectMiddleware(BaseRedirectMiddleware): return response if response.status in (301, 307, 308) or request.method == "HEAD": - return self._redirect(redirected, request, spider, response.status) + return self._redirect(redirected, request, response.status) redirected = self._redirect_request_using_get(request, redirected_url) - return self._redirect(redirected, request, spider, response.status) + return self._redirect(redirected, request, response.status) class MetaRefreshMiddleware(BaseRedirectMiddleware): @@ -185,8 +189,9 @@ class MetaRefreshMiddleware(BaseRedirectMiddleware): self._ignore_tags: list[str] = settings.getlist("METAREFRESH_IGNORE_TAGS") self._maxdelay: int = settings.getint("METAREFRESH_MAXDELAY") + @_warn_spider_arg def process_response( - self, request: Request, response: Response, spider: Spider + self, request: Request, response: Response, spider: Spider | None = None ) -> Request | Response: if ( request.meta.get("dont_redirect", False) @@ -202,6 +207,6 @@ class MetaRefreshMiddleware(BaseRedirectMiddleware): redirected = self._redirect_request_using_get(request, url) if urlparse_cached(redirected).scheme not in {"http", "https"}: return response - if cast(float, interval) < self._maxdelay: - return self._redirect(redirected, request, spider, "meta refresh") + if cast("float", interval) < self._maxdelay: + return self._redirect(redirected, request, "meta refresh") return response diff --git a/scrapy/downloadermiddlewares/retry.py b/scrapy/downloadermiddlewares/retry.py index 723fe5e93..b6dba5773 100644 --- a/scrapy/downloadermiddlewares/retry.py +++ b/scrapy/downloadermiddlewares/retry.py @@ -16,6 +16,7 @@ from logging import Logger, getLogger from typing import TYPE_CHECKING from scrapy.exceptions import NotConfigured +from scrapy.utils.decorators import _warn_spider_arg from scrapy.utils.misc import load_object from scrapy.utils.python import global_object_name from scrapy.utils.response import response_status_message @@ -123,6 +124,8 @@ def get_retry_request( class RetryMiddleware: + crawler: Crawler + def __init__(self, settings: BaseSettings): if not settings.getbool("RETRY_ENABLED"): raise NotConfigured @@ -136,39 +139,41 @@ class RetryMiddleware: @classmethod def from_crawler(cls, crawler: Crawler) -> Self: - return cls(crawler.settings) + o = cls(crawler.settings) + o.crawler = crawler + return o + @_warn_spider_arg def process_response( - self, request: Request, response: Response, spider: Spider + self, request: Request, response: Response, spider: Spider | None = None ) -> Request | Response: if request.meta.get("dont_retry", False): return response if response.status in self.retry_http_codes: reason = response_status_message(response.status) - return self._retry(request, reason, spider) or response + return self._retry(request, reason) or response return response + @_warn_spider_arg def process_exception( - self, request: Request, exception: Exception, spider: Spider + self, request: Request, exception: Exception, spider: Spider | None = None ) -> Request | Response | None: if isinstance(exception, self.exceptions_to_retry) and not request.meta.get( "dont_retry", False ): - return self._retry(request, exception, spider) + return self._retry(request, exception) return None def _retry( - self, - request: Request, - reason: str | Exception | type[Exception], - spider: Spider, + self, request: Request, reason: str | Exception | type[Exception] ) -> Request | None: max_retry_times = request.meta.get("max_retry_times", self.max_retry_times) priority_adjust = request.meta.get("priority_adjust", self.priority_adjust) + assert self.crawler.spider return get_retry_request( request, reason=reason, - spider=spider, + spider=self.crawler.spider, max_retry_times=max_retry_times, priority_adjust=priority_adjust, ) diff --git a/scrapy/downloadermiddlewares/robotstxt.py b/scrapy/downloadermiddlewares/robotstxt.py index fbd737970..7d0c17884 100644 --- a/scrapy/downloadermiddlewares/robotstxt.py +++ b/scrapy/downloadermiddlewares/robotstxt.py @@ -9,18 +9,17 @@ from __future__ import annotations import logging from typing import TYPE_CHECKING -from twisted.internet.defer import Deferred, maybeDeferred +from twisted.internet.defer import Deferred from scrapy.exceptions import IgnoreRequest, NotConfigured from scrapy.http import Request, Response from scrapy.http.request import NO_CALLBACK +from scrapy.utils.decorators import _warn_spider_arg +from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.httpobj import urlparse_cached -from scrapy.utils.log import failure_to_exc_info from scrapy.utils.misc import load_object if TYPE_CHECKING: - from twisted.python.failure import Failure - # typing.Self requires Python 3.11 from typing_extensions import Self @@ -53,24 +52,18 @@ class RobotsTxtMiddleware: def from_crawler(cls, crawler: Crawler) -> Self: return cls(crawler) - def process_request( - self, request: Request, spider: Spider - ) -> Deferred[None] | None: - if request.meta.get("dont_obey_robotstxt"): - return None - if request.url.startswith("data:") or request.url.startswith("file:"): - return None - d: Deferred[RobotParser | None] = maybeDeferred( - self.robot_parser, - request, - spider, # type: ignore[call-overload] - ) - d2: Deferred[None] = d.addCallback(self.process_request_2, request, spider) - return d2 - - def process_request_2( - self, rp: RobotParser | None, request: Request, spider: Spider + @_warn_spider_arg + async def process_request( + self, request: Request, spider: Spider | None = None ) -> None: + if request.meta.get("dont_obey_robotstxt"): + return + if request.url.startswith("data:") or request.url.startswith("file:"): + return + rp = await self.robot_parser(request) + self.process_request_2(rp, request) + + def process_request_2(self, rp: RobotParser | None, request: Request) -> None: if rp is None: return @@ -82,15 +75,13 @@ class RobotsTxtMiddleware: logger.debug( "Forbidden by robots.txt: %(request)s", {"request": request}, - extra={"spider": spider}, + extra={"spider": self.crawler.spider}, ) assert self.crawler.stats self.crawler.stats.inc_value("robotstxt/forbidden") raise IgnoreRequest("Forbidden by robots.txt") - def robot_parser( - self, request: Request, spider: Spider - ) -> RobotParser | Deferred[RobotParser | None] | None: + async def robot_parser(self, request: Request) -> RobotParser | None: url = urlparse_cached(request) netloc = url.netloc @@ -105,35 +96,26 @@ class RobotsTxtMiddleware: ) assert self.crawler.engine assert self.crawler.stats - dfd = self.crawler.engine.download(robotsreq) - dfd.addCallback(self._parse_robots, netloc, spider) - dfd.addErrback(self._logerror, robotsreq, spider) - dfd.addErrback(self._robots_error, netloc) + try: + resp = await self.crawler.engine.download_async(robotsreq) + self._parse_robots(resp, netloc) + except Exception as e: + if not isinstance(e, IgnoreRequest): + logger.error( + "Error downloading %(request)s: %(f_exception)s", + {"request": request, "f_exception": e}, + exc_info=True, + extra={"spider": self.crawler.spider}, + ) + self._robots_error(e, netloc) self.crawler.stats.inc_value("robotstxt/request_count") parser = self._parsers[netloc] if isinstance(parser, Deferred): - d: Deferred[RobotParser | None] = Deferred() - - def cb(result: RobotParser | None) -> RobotParser | None: - d.callback(result) - return result - - parser.addCallback(cb) - return d + return await maybe_deferred_to_future(parser) return parser - def _logerror(self, failure: Failure, request: Request, spider: Spider) -> Failure: - if failure.type is not IgnoreRequest: - logger.error( - "Error downloading %(request)s: %(f_exception)s", - {"request": request, "f_exception": failure.value}, - exc_info=failure_to_exc_info(failure), - extra={"spider": spider}, - ) - return failure - - def _parse_robots(self, response: Response, netloc: str, spider: Spider) -> None: + def _parse_robots(self, response: Response, netloc: str) -> None: assert self.crawler.stats self.crawler.stats.inc_value("robotstxt/response_count") self.crawler.stats.inc_value( @@ -145,9 +127,9 @@ class RobotsTxtMiddleware: self._parsers[netloc] = rp rp_dfd.callback(rp) - def _robots_error(self, failure: Failure, netloc: str) -> None: - if failure.type is not IgnoreRequest: - key = f"robotstxt/exception_count/{failure.type}" + def _robots_error(self, exc: Exception, netloc: str) -> None: + if not isinstance(exc, IgnoreRequest): + key = f"robotstxt/exception_count/{type(exc)}" assert self.crawler.stats self.crawler.stats.inc_value(key) rp_dfd = self._parsers[netloc] diff --git a/scrapy/downloadermiddlewares/stats.py b/scrapy/downloadermiddlewares/stats.py index cb5887a6f..bafa931de 100644 --- a/scrapy/downloadermiddlewares/stats.py +++ b/scrapy/downloadermiddlewares/stats.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING from twisted.web import http from scrapy.exceptions import NotConfigured +from scrapy.utils.decorators import _warn_spider_arg from scrapy.utils.python import global_object_name, to_bytes from scrapy.utils.request import request_httprepr @@ -45,24 +46,22 @@ class DownloaderStats: assert crawler.stats return cls(crawler.stats) + @_warn_spider_arg def process_request( - self, request: Request, spider: Spider + self, request: Request, spider: Spider | None = None ) -> Request | Response | None: - self.stats.inc_value("downloader/request_count", spider=spider) - self.stats.inc_value( - f"downloader/request_method_count/{request.method}", spider=spider - ) + self.stats.inc_value("downloader/request_count") + self.stats.inc_value(f"downloader/request_method_count/{request.method}") reqlen = len(request_httprepr(request)) - self.stats.inc_value("downloader/request_bytes", reqlen, spider=spider) + self.stats.inc_value("downloader/request_bytes", reqlen) return None + @_warn_spider_arg def process_response( - self, request: Request, response: Response, spider: Spider + self, request: Request, response: Response, spider: Spider | None = None ) -> Request | Response: - self.stats.inc_value("downloader/response_count", spider=spider) - self.stats.inc_value( - f"downloader/response_status_count/{response.status}", spider=spider - ) + self.stats.inc_value("downloader/response_count") + self.stats.inc_value(f"downloader/response_status_count/{response.status}") reslen = ( len(response.body) + get_header_size(response.headers) @@ -70,15 +69,14 @@ class DownloaderStats: + 4 ) # response.body + b"\r\n"+ response.header + b"\r\n" + response.status - self.stats.inc_value("downloader/response_bytes", reslen, spider=spider) + self.stats.inc_value("downloader/response_bytes", reslen) return response + @_warn_spider_arg def process_exception( - self, request: Request, exception: Exception, spider: Spider + self, request: Request, exception: Exception, spider: Spider | None = None ) -> Request | Response | None: ex_class = global_object_name(exception.__class__) - self.stats.inc_value("downloader/exception_count", spider=spider) - self.stats.inc_value( - f"downloader/exception_type_count/{ex_class}", spider=spider - ) + self.stats.inc_value("downloader/exception_count") + self.stats.inc_value(f"downloader/exception_type_count/{ex_class}") return None diff --git a/scrapy/downloadermiddlewares/useragent.py b/scrapy/downloadermiddlewares/useragent.py index ba379f862..c43a0195c 100644 --- a/scrapy/downloadermiddlewares/useragent.py +++ b/scrapy/downloadermiddlewares/useragent.py @@ -5,6 +5,7 @@ from __future__ import annotations from typing import TYPE_CHECKING from scrapy import Request, Spider, signals +from scrapy.utils.decorators import _warn_spider_arg if TYPE_CHECKING: # typing.Self requires Python 3.11 @@ -29,8 +30,9 @@ class UserAgentMiddleware: def spider_opened(self, spider: Spider) -> None: self.user_agent = getattr(spider, "user_agent", self.user_agent) + @_warn_spider_arg def process_request( - self, request: Request, spider: Spider + self, request: Request, spider: Spider | None = None ) -> Request | Response | None: if self.user_agent: request.headers.setdefault(b"User-Agent", self.user_agent) diff --git a/scrapy/dupefilters.py b/scrapy/dupefilters.py index a3e2c5eb4..f0a6988c8 100644 --- a/scrapy/dupefilters.py +++ b/scrapy/dupefilters.py @@ -1,7 +1,6 @@ from __future__ import annotations import logging -import warnings from pathlib import Path from typing import TYPE_CHECKING from warnings import warn @@ -22,7 +21,6 @@ if TYPE_CHECKING: from scrapy.crawler import Crawler from scrapy.http.request import Request - from scrapy.settings import BaseSettings from scrapy.spiders import Spider @@ -30,15 +28,6 @@ class BaseDupeFilter: """Dummy duplicate request filtering class (:setting:`DUPEFILTER_CLASS`) that does not filter out any request.""" - @classmethod - def from_settings(cls, settings: BaseSettings) -> Self: - warnings.warn( - f"{cls.__name__}.from_settings() is deprecated, use from_crawler() instead.", - category=ScrapyDeprecationWarning, - stacklevel=2, - ) - return cls() - @classmethod def from_crawler(cls, crawler: Crawler) -> Self: return cls() @@ -84,42 +73,24 @@ class RFPDupeFilter(BaseDupeFilter): self.debug = debug self.logger = logging.getLogger(__name__) if path: - self.file = Path(path, "requests.seen").open("a+", encoding="utf-8") + # line-by-line writing, see: https://github.com/scrapy/scrapy/issues/6019 + self.file = Path(path, "requests.seen").open( + "a+", buffering=1, encoding="utf-8" + ) + self.file.reconfigure(write_through=True) self.file.seek(0) self.fingerprints.update(x.rstrip() for x in self.file) - @classmethod - def from_settings( - cls, - settings: BaseSettings, - *, - fingerprinter: RequestFingerprinterProtocol | None = None, - ) -> Self: - warnings.warn( - f"{cls.__name__}.from_settings() is deprecated, use from_crawler() instead.", - category=ScrapyDeprecationWarning, - stacklevel=2, - ) - return cls._from_settings(settings, fingerprinter=fingerprinter) - @classmethod def from_crawler(cls, crawler: Crawler) -> Self: assert crawler.request_fingerprinter - return cls._from_settings( - crawler.settings, + debug = crawler.settings.getbool("DUPEFILTER_DEBUG") + return cls( + job_dir(crawler.settings), + debug, fingerprinter=crawler.request_fingerprinter, ) - @classmethod - def _from_settings( - cls, - settings: BaseSettings, - *, - fingerprinter: RequestFingerprinterProtocol | None = None, - ) -> Self: - debug = settings.getbool("DUPEFILTER_DEBUG") - return cls(job_dir(settings), debug, fingerprinter=fingerprinter) - def request_seen(self, request: Request) -> bool: fp = self.request_fingerprint(request) if fp in self.fingerprints: @@ -152,4 +123,4 @@ class RFPDupeFilter(BaseDupeFilter): self.logdupes = False assert spider.crawler.stats - spider.crawler.stats.inc_value("dupefilter/filtered", spider=spider) + spider.crawler.stats.inc_value("dupefilter/filtered") diff --git a/scrapy/exporters.py b/scrapy/exporters.py index 0a641752e..e18f1e6ed 100644 --- a/scrapy/exporters.py +++ b/scrapy/exporters.py @@ -8,6 +8,7 @@ import csv import marshal import pickle import pprint +from abc import ABC, abstractmethod from collections.abc import Callable, Iterable, Mapping from io import BytesIO, TextIOWrapper from typing import TYPE_CHECKING, Any @@ -35,7 +36,7 @@ __all__ = [ ] -class BaseItemExporter: +class BaseItemExporter(ABC): def __init__(self, *, dont_fail: bool = False, **kwargs: Any): self._kwargs: dict[str, Any] = kwargs self._configure(kwargs, dont_fail=dont_fail) @@ -54,6 +55,7 @@ class BaseItemExporter: if not dont_fail and options: raise TypeError(f"Unexpected options: {', '.join(options.keys())}") + @abstractmethod def export_item(self, item: Any) -> None: raise NotImplementedError @@ -63,10 +65,10 @@ class BaseItemExporter: serializer: Callable[[Any], Any] = field.get("serializer", lambda x: x) return serializer(value) - def start_exporting(self) -> None: + def start_exporting(self) -> None: # noqa: B027 pass - def finish_exporting(self) -> None: + def finish_exporting(self) -> None: # noqa: B027 pass def _get_serialized_fields( diff --git a/scrapy/extensions/closespider.py b/scrapy/extensions/closespider.py index b4c6c73a0..a4362b182 100644 --- a/scrapy/extensions/closespider.py +++ b/scrapy/extensions/closespider.py @@ -18,6 +18,7 @@ from scrapy.utils.asyncio import ( call_later, create_looping_call, ) +from scrapy.utils.defer import _schedule_coro if TYPE_CHECKING: from twisted.internet.task import LoopingCall @@ -86,38 +87,31 @@ class CloseSpider: def error_count(self, failure: Failure, response: Response, spider: Spider) -> None: self.counter["errorcount"] += 1 if self.counter["errorcount"] == self.close_on["errorcount"]: - assert self.crawler.engine - self.crawler.engine.close_spider(spider, "closespider_errorcount") + self._close_spider("closespider_errorcount") def page_count(self, response: Response, request: Request, spider: Spider) -> None: self.counter["pagecount"] += 1 self.counter["pagecount_since_last_item"] += 1 if self.counter["pagecount"] == self.close_on["pagecount"]: - assert self.crawler.engine - self.crawler.engine.close_spider(spider, "closespider_pagecount") + self._close_spider("closespider_pagecount") return if self.close_on["pagecount_no_item"] and ( self.counter["pagecount_since_last_item"] >= self.close_on["pagecount_no_item"] ): - assert self.crawler.engine - self.crawler.engine.close_spider(spider, "closespider_pagecount_no_item") + self._close_spider("closespider_pagecount_no_item") def spider_opened(self, spider: Spider) -> None: assert self.crawler.engine self.task = call_later( - self.close_on["timeout"], - self.crawler.engine.close_spider, - spider, - "closespider_timeout", + self.close_on["timeout"], self._close_spider, "closespider_timeout" ) def item_scraped(self, item: Any, spider: Spider) -> None: self.counter["itemcount"] += 1 self.counter["pagecount_since_last_item"] = 0 if self.counter["itemcount"] == self.close_on["itemcount"]: - assert self.crawler.engine - self.crawler.engine.close_spider(spider, "closespider_itemcount") + self._close_spider("closespider_itemcount") def spider_closed(self, spider: Spider) -> None: if self.task: @@ -130,7 +124,7 @@ class CloseSpider: self.task_no_item = None def spider_opened_no_item(self, spider: Spider) -> None: - self.task_no_item = create_looping_call(self._count_items_produced, spider) + self.task_no_item = create_looping_call(self._count_items_produced) self.task_no_item.start(self.timeout_no_item, now=False) logger.info( @@ -141,7 +135,7 @@ class CloseSpider: def item_scraped_no_item(self, item: Any, spider: Spider) -> None: self.items_in_period += 1 - def _count_items_produced(self, spider: Spider) -> None: + def _count_items_produced(self) -> None: if self.items_in_period >= 1: self.items_in_period = 0 else: @@ -149,5 +143,8 @@ class CloseSpider: f"Closing spider since no items were produced in the last " f"{self.timeout_no_item} seconds." ) - assert self.crawler.engine - self.crawler.engine.close_spider(spider, "closespider_timeout_no_item") + self._close_spider("closespider_timeout_no_item") + + def _close_spider(self, reason: str) -> None: + assert self.crawler.engine + _schedule_coro(self.crawler.engine.close_spider_async(reason=reason)) diff --git a/scrapy/extensions/corestats.py b/scrapy/extensions/corestats.py index 779cd5d1c..1400f4c9b 100644 --- a/scrapy/extensions/corestats.py +++ b/scrapy/extensions/corestats.py @@ -35,26 +35,24 @@ class CoreStats: def spider_opened(self, spider: Spider) -> None: self.start_time = datetime.now(tz=timezone.utc) - self.stats.set_value("start_time", self.start_time, spider=spider) + self.stats.set_value("start_time", self.start_time) def spider_closed(self, spider: Spider, reason: str) -> None: assert self.start_time is not None finish_time = datetime.now(tz=timezone.utc) elapsed_time = finish_time - self.start_time elapsed_time_seconds = elapsed_time.total_seconds() - self.stats.set_value( - "elapsed_time_seconds", elapsed_time_seconds, spider=spider - ) - self.stats.set_value("finish_time", finish_time, spider=spider) - self.stats.set_value("finish_reason", reason, spider=spider) + self.stats.set_value("elapsed_time_seconds", elapsed_time_seconds) + self.stats.set_value("finish_time", finish_time) + self.stats.set_value("finish_reason", reason) def item_scraped(self, item: Any, spider: Spider) -> None: - self.stats.inc_value("item_scraped_count", spider=spider) + self.stats.inc_value("item_scraped_count") def response_received(self, spider: Spider) -> None: - self.stats.inc_value("response_received_count", spider=spider) + self.stats.inc_value("response_received_count") def item_dropped(self, item: Any, spider: Spider, exception: BaseException) -> None: reason = exception.__class__.__name__ - self.stats.inc_value("item_dropped_count", spider=spider) - self.stats.inc_value(f"item_dropped_reasons_count/{reason}", spider=spider) + self.stats.inc_value("item_dropped_count") + self.stats.inc_value(f"item_dropped_reasons_count/{reason}") diff --git a/scrapy/extensions/feedexport.py b/scrapy/extensions/feedexport.py index d9e9ea775..adb6fdc33 100644 --- a/scrapy/extensions/feedexport.py +++ b/scrapy/extensions/feedexport.py @@ -11,11 +11,12 @@ import logging import re import sys import warnings +from abc import ABC, abstractmethod from collections.abc import Callable from datetime import datetime, timezone from pathlib import Path, PureWindowsPath from tempfile import NamedTemporaryFile -from typing import IO, TYPE_CHECKING, Any, Optional, Protocol, TypeVar, cast +from typing import IO, TYPE_CHECKING, Any, Protocol, TypeAlias, cast from urllib.parse import unquote, urlparse from twisted.internet.defer import Deferred, DeferredList, maybeDeferred @@ -34,8 +35,6 @@ from scrapy.utils.misc import build_from_crawler, load_object from scrapy.utils.python import without_none_values if TYPE_CHECKING: - from collections.abc import Iterable - from _typeshed import OpenBinaryMode from twisted.python.failure import Failure @@ -49,26 +48,9 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) -UriParamsCallableT = Callable[[dict[str, Any], Spider], Optional[dict[str, Any]]] - -_StorageT = TypeVar("_StorageT", bound="FeedStorageProtocol") - - -def build_storage( - builder: Callable[..., _StorageT], - uri: str, - *args: Any, - feed_options: dict[str, Any] | None = None, - preargs: Iterable[Any] = (), - **kwargs: Any, -) -> _StorageT: - warnings.warn( - "scrapy.extensions.feedexport.build_storage() is deprecated, call the builder directly.", - category=ScrapyDeprecationWarning, - stacklevel=2, - ) - kwargs["feed_options"] = feed_options - return builder(*preargs, uri, *args, **kwargs) +UriParamsCallableT: TypeAlias = Callable[ + [dict[str, Any], Spider], dict[str, Any] | None +] class ItemFilter: @@ -140,7 +122,7 @@ class FeedStorageProtocol(Protocol): @implementer(IFeedStorage) -class BlockingFeedStorage: +class BlockingFeedStorage(ABC): def open(self, spider: Spider) -> IO[bytes]: path = spider.crawler.settings["FEED_TEMPDIR"] if path and not Path(path).is_dir(): @@ -151,6 +133,7 @@ class BlockingFeedStorage: def store(self, file: IO[bytes]) -> Deferred[None] | None: return deferToThread(self._store_in_thread, file) + @abstractmethod def _store_in_thread(self, file: IO[bytes]) -> None: raise NotImplementedError @@ -185,7 +168,7 @@ class StdoutFeedStorage: @implementer(IFeedStorage) class FileFeedStorage: def __init__(self, uri: str, *, feed_options: dict[str, Any] | None = None): - self.path: str = file_uri_to_path(uri) + self.path: str = file_uri_to_path(uri) if uri.startswith("file://") else uri feed_options = feed_options or {} self.write_mode: OpenBinaryMode = ( "wb" if feed_options.get("overwrite", False) else "ab" @@ -216,7 +199,7 @@ class S3FeedStorage(BlockingFeedStorage): region_name: str | None = None, ): try: - import boto3.session + import boto3.session # noqa: PLC0415 except ImportError: raise NotConfigured("missing boto3 library") u = urlparse(uri) @@ -315,7 +298,7 @@ class GCSFeedStorage(BlockingFeedStorage): def _store_in_thread(self, file: IO[bytes]) -> None: file.seek(0) - from google.cloud.storage import Client + from google.cloud.storage import Client # noqa: PLC0415 client = Client(project=self.project_id) bucket = client.get_bucket(self.bucket_name) @@ -374,11 +357,11 @@ class FeedSlot: self, storage: FeedStorageProtocol, uri: str, - format: str, + format: str, # noqa: A002 store_empty: bool, batch_id: int, uri_template: str, - filter: ItemFilter, + filter: ItemFilter, # noqa: A002 feed_options: dict[str, Any], spider: Spider, exporters: dict[str, type[BaseItemExporter]], @@ -411,7 +394,7 @@ class FeedSlot: self.file = self.storage.open(self.spider) if "postprocessing" in self.feed_options: self.file = cast( - IO[bytes], + "IO[bytes]", PostProcessingManager( self.feed_options["postprocessing"], self.file, @@ -420,7 +403,7 @@ class FeedSlot: ) self.exporter = self._get_exporter( file=self.file, - format=self.feed_options["format"], + format_=self.feed_options["format"], fields_to_export=self.feed_options["fields"], encoding=self.feed_options["encoding"], indent=self.feed_options["indent"], @@ -434,10 +417,10 @@ class FeedSlot: self._exporting = True def _get_exporter( - self, file: IO[bytes], format: str, *args: Any, **kwargs: Any + self, file: IO[bytes], format_: str, *args: Any, **kwargs: Any ) -> BaseItemExporter: return build_from_crawler( - self.exporters[format], self.crawler, file, *args, **kwargs + self.exporters[format_], self.crawler, file, *args, **kwargs ) def finish_exporting(self) -> None: @@ -660,7 +643,7 @@ class FeedExporter: def _load_components(self, setting_prefix: str) -> dict[str, Any]: conf = without_none_values( - cast(dict[str, str], self.settings.getwithbase(setting_prefix)) + cast("dict[str, str]", self.settings.getwithbase(setting_prefix)) ) d = {} for k, v in conf.items(): @@ -668,10 +651,10 @@ class FeedExporter: d[k] = load_object(v) return d - def _exporter_supported(self, format: str) -> bool: - if format in self.exporters: + def _exporter_supported(self, format_: str) -> bool: + if format_ in self.exporters: return True - logger.error("Unknown feed format: %(format)s", {"format": format}) + logger.error("Unknown feed format: %(format)s", {"format": format_}) return False def _settings_are_valid(self) -> bool: diff --git a/scrapy/extensions/httpcache.py b/scrapy/extensions/httpcache.py index 0cd16d737..efda50a82 100644 --- a/scrapy/extensions/httpcache.py +++ b/scrapy/extensions/httpcache.py @@ -7,7 +7,7 @@ from email.utils import mktime_tz, parsedate_tz from importlib import import_module from pathlib import Path from time import time -from typing import IO, TYPE_CHECKING, Any, cast +from typing import IO, TYPE_CHECKING, Any, Concatenate, cast from weakref import WeakKeyDictionary from w3lib.http import headers_dict_to_raw, headers_raw_to_dict @@ -23,9 +23,6 @@ if TYPE_CHECKING: from collections.abc import Callable from types import ModuleType - # typing.Concatenate requires Python 3.10 - from typing_extensions import Concatenate - from scrapy.http.request import Request from scrapy.settings import BaseSettings from scrapy.spiders import Spider @@ -307,7 +304,7 @@ class DbmCacheStorage: if 0 < self.expiration_secs < time() - float(ts): return None # expired - return cast(dict[str, Any], pickle.loads(db[f"{key}_data"])) # noqa: S301 + return cast("dict[str, Any]", pickle.loads(db[f"{key}_data"])) # noqa: S301 class FilesystemCacheStorage: @@ -389,7 +386,7 @@ class FilesystemCacheStorage: if 0 < self.expiration_secs < time() - mtime: return None # expired with self._open(metapath, "rb") as f: - return cast(dict[str, Any], pickle.load(f)) # noqa: S301 + return cast("dict[str, Any]", pickle.load(f)) # noqa: S301 def parse_cachecontrol(header: bytes) -> dict[bytes, bytes | None]: diff --git a/scrapy/extensions/logcount.py b/scrapy/extensions/logcount.py new file mode 100644 index 000000000..04e570bbf --- /dev/null +++ b/scrapy/extensions/logcount.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from scrapy import Spider, signals +from scrapy.utils.log import LogCounterHandler + +if TYPE_CHECKING: + # typing.Self requires Python 3.11 + from typing_extensions import Self + + from scrapy.crawler import Crawler + + +logger = logging.getLogger(__name__) + + +class LogCount: + """Install a log handler that counts log messages by level. + + The handler installed is :class:`scrapy.utils.log.LogCounterHandler`. + The counts are stored in stats as ``log_count/``. + + .. versionadded:: VERSION + """ + + def __init__(self, crawler: Crawler): + self.crawler: Crawler = crawler + self.handler: LogCounterHandler | None = None + + @classmethod + def from_crawler(cls, crawler: Crawler) -> Self: + o = cls(crawler) + crawler.signals.connect(o.spider_opened, signal=signals.spider_opened) + crawler.signals.connect(o.spider_closed, signal=signals.spider_closed) + return o + + def spider_opened(self, spider: Spider) -> None: + self.handler = LogCounterHandler( + self.crawler, level=self.crawler.settings.get("LOG_LEVEL") + ) + logging.root.addHandler(self.handler) + + def spider_closed(self, spider: Spider, reason: str) -> None: + if self.handler: + logging.root.removeHandler(self.handler) + self.handler = None diff --git a/scrapy/extensions/logstats.py b/scrapy/extensions/logstats.py index 387cfddb3..3d7674905 100644 --- a/scrapy/extensions/logstats.py +++ b/scrapy/extensions/logstats.py @@ -5,10 +5,7 @@ from typing import TYPE_CHECKING from scrapy import Spider, signals from scrapy.exceptions import NotConfigured -from scrapy.utils.asyncio import ( - AsyncioLoopingCall, - create_looping_call, -) +from scrapy.utils.asyncio import AsyncioLoopingCall, create_looping_call if TYPE_CHECKING: from twisted.internet.task import LoopingCall diff --git a/scrapy/extensions/memdebug.py b/scrapy/extensions/memdebug.py index 3cbbb64e5..1fde6b296 100644 --- a/scrapy/extensions/memdebug.py +++ b/scrapy/extensions/memdebug.py @@ -36,12 +36,8 @@ class MemoryDebugger: def spider_closed(self, spider: Spider, reason: str) -> None: gc.collect() - self.stats.set_value( - "memdebug/gc_garbage_count", len(gc.garbage), spider=spider - ) + self.stats.set_value("memdebug/gc_garbage_count", len(gc.garbage)) for cls, wdict in live_refs.items(): if not wdict: continue - self.stats.set_value( - f"memdebug/live_refs/{cls.__name__}", len(wdict), spider=spider - ) + self.stats.set_value(f"memdebug/live_refs/{cls.__name__}", len(wdict)) diff --git a/scrapy/extensions/memusage.py b/scrapy/extensions/memusage.py index 2ef322f1a..990c144b5 100644 --- a/scrapy/extensions/memusage.py +++ b/scrapy/extensions/memusage.py @@ -16,10 +16,8 @@ from typing import TYPE_CHECKING from scrapy import signals from scrapy.exceptions import NotConfigured from scrapy.mail import MailSender -from scrapy.utils.asyncio import ( - AsyncioLoopingCall, - create_looping_call, -) +from scrapy.utils.asyncio import AsyncioLoopingCall, create_looping_call +from scrapy.utils.defer import _schedule_coro from scrapy.utils.engine import get_engine_status if TYPE_CHECKING: @@ -113,11 +111,11 @@ class MemoryUsage: self.crawler.stats.set_value("memusage/limit_notified", 1) if self.crawler.engine.spider is not None: - self.crawler.engine.close_spider( - self.crawler.engine.spider, "memusage_exceeded" + _schedule_coro( + self.crawler.engine.close_spider_async(reason="memusage_exceeded") ) else: - self.crawler.stop() + _schedule_coro(self.crawler.stop_async()) else: logger.info( "Peak memory usage is %(virtualsize)dMiB", diff --git a/scrapy/extensions/periodic_log.py b/scrapy/extensions/periodic_log.py index 98210990a..860b97a55 100644 --- a/scrapy/extensions/periodic_log.py +++ b/scrapy/extensions/periodic_log.py @@ -6,10 +6,7 @@ from typing import TYPE_CHECKING, Any from scrapy import Spider, signals from scrapy.exceptions import NotConfigured -from scrapy.utils.asyncio import ( - AsyncioLoopingCall, - create_looping_call, -) +from scrapy.utils.asyncio import AsyncioLoopingCall, create_looping_call from scrapy.utils.serialize import ScrapyJSONEncoder if TYPE_CHECKING: diff --git a/scrapy/extensions/postprocessing.py b/scrapy/extensions/postprocessing.py index b1fa160c8..5828ae52e 100644 --- a/scrapy/extensions/postprocessing.py +++ b/scrapy/extensions/postprocessing.py @@ -92,14 +92,14 @@ class LZMAPlugin: self.file = file self.feed_options = feed_options - format = self.feed_options.get("lzma_format") + format_ = self.feed_options.get("lzma_format") check = self.feed_options.get("lzma_check", -1) preset = self.feed_options.get("lzma_preset") filters = self.feed_options.get("lzma_filters") self.lzmafile = LZMAFile( filename=self.file, mode="wb", - format=format, + format=format_, check=check, preset=preset, filters=filters, @@ -142,7 +142,7 @@ class PostProcessingManager(IOBase): :return: returns number of bytes written :rtype: int """ - return cast(int, self.head_plugin.write(data)) + return cast("int", self.head_plugin.write(data)) def tell(self) -> int: return self.file.tell() diff --git a/scrapy/extensions/statsmailer.py b/scrapy/extensions/statsmailer.py index 221628642..9e900308d 100644 --- a/scrapy/extensions/statsmailer.py +++ b/scrapy/extensions/statsmailer.py @@ -40,7 +40,7 @@ class StatsMailer: return o def spider_closed(self, spider: Spider) -> Deferred[None] | None: - spider_stats = self.stats.get_stats(spider) + spider_stats = self.stats.get_stats() body = "Global stats\n\n" body += "\n".join(f"{k:<50} : {v}" for k, v in self.stats.get_stats().items()) body += f"\n\n{spider.name} stats\n\n" diff --git a/scrapy/extensions/telnet.py b/scrapy/extensions/telnet.py index bacee8f0a..094a0195e 100644 --- a/scrapy/extensions/telnet.py +++ b/scrapy/extensions/telnet.py @@ -12,6 +12,8 @@ import os import pprint from typing import TYPE_CHECKING, Any +from twisted.conch import telnet +from twisted.conch.insults import insults from twisted.internet import protocol from scrapy import signals @@ -22,7 +24,6 @@ from scrapy.utils.reactor import listen_tcp from scrapy.utils.trackref import print_live_refs if TYPE_CHECKING: - from twisted.conch import telnet from twisted.internet.tcp import Port # typing.Self requires Python 3.11 @@ -76,10 +77,6 @@ class TelnetConsole(protocol.ServerFactory): self.port.stopListening() def protocol(self) -> telnet.TelnetTransport: - # these import twisted.internet.reactor - from twisted.conch import manhole, telnet - from twisted.conch.insults import insults - class Portal: """An implementation of IPortal""" @@ -91,6 +88,8 @@ class TelnetConsole(protocol.ServerFactory): ): raise ValueError("Invalid credentials") + from twisted.conch import manhole + protocol = telnet.TelnetBootstrapProtocol( insults.ServerProtocol, manhole.Manhole, self._get_telnet_vars() ) diff --git a/scrapy/http/cookies.py b/scrapy/http/cookies.py index b7c3b9d37..e5b4b28a7 100644 --- a/scrapy/http/cookies.py +++ b/scrapy/http/cookies.py @@ -161,7 +161,7 @@ class WrappedRequest: HTML document, and the user had no option to approve the automatic fetching of the image, this should be true. """ - return cast(bool, self.request.meta.get("is_unverifiable", False)) + return cast("bool", self.request.meta.get("is_unverifiable", False)) @property def full_url(self) -> str: @@ -181,7 +181,7 @@ class WrappedRequest: @property def origin_req_host(self) -> str: - return cast(str, urlparse_cached(self.request).hostname) + return cast("str", urlparse_cached(self.request).hostname) def has_header(self, name: str) -> bool: return name in self.request.headers diff --git a/scrapy/http/headers.py b/scrapy/http/headers.py index 60b04753b..f3f60bffb 100644 --- a/scrapy/http/headers.py +++ b/scrapy/http/headers.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, AnyStr, Union, cast +from typing import TYPE_CHECKING, Any, AnyStr, TypeAlias, cast from w3lib.http import headers_dict_to_raw @@ -15,7 +15,7 @@ if TYPE_CHECKING: from typing_extensions import Self -_RawValueT = Union[bytes, str, int] +_RawValue: TypeAlias = bytes | str | int # isn't fully compatible typing-wise with either dict or CaselessDict, @@ -44,9 +44,9 @@ class Headers(CaselessDict): """Normalize key to bytes""" return self._tobytes(key.title()) - def normvalue(self, value: _RawValueT | Iterable[_RawValueT]) -> list[bytes]: + def normvalue(self, value: _RawValue | Iterable[_RawValue]) -> list[bytes]: """Normalize values to bytes""" - _value: Iterable[_RawValueT] + _value: Iterable[_RawValue] if value is None: _value = [] elif isinstance(value, (str, bytes)): @@ -58,7 +58,7 @@ class Headers(CaselessDict): return [self._tobytes(x) for x in _value] - def _tobytes(self, x: _RawValueT) -> bytes: + def _tobytes(self, x: _RawValue) -> bytes: if isinstance(x, bytes): return x if isinstance(x, str): @@ -69,33 +69,33 @@ class Headers(CaselessDict): def __getitem__(self, key: AnyStr) -> bytes | None: try: - return cast(list[bytes], super().__getitem__(key))[-1] + return cast("list[bytes]", super().__getitem__(key))[-1] except IndexError: return None def get(self, key: AnyStr, def_val: Any = None) -> bytes | None: try: - return cast(list[bytes], super().get(key, def_val))[-1] + 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]: try: - return cast(list[bytes], super().__getitem__(key)) + return cast("list[bytes]", super().__getitem__(key)) except KeyError: if def_val is not None: return self.normvalue(def_val) return [] - def setlist(self, key: AnyStr, list_: Iterable[_RawValueT]) -> None: + def setlist(self, key: AnyStr, list_: Iterable[_RawValue]) -> None: self[key] = list_ def setlistdefault( - self, key: AnyStr, default_list: Iterable[_RawValueT] = () + self, key: AnyStr, default_list: Iterable[_RawValue] = () ) -> Any: return self.setdefault(key, default_list) - def appendlist(self, key: AnyStr, value: Iterable[_RawValueT]) -> None: + def appendlist(self, key: AnyStr, value: Iterable[_RawValue]) -> None: lst = self.getlist(key) lst.extend(self.normvalue(value)) self[key] = lst diff --git a/scrapy/http/request/__init__.py b/scrapy/http/request/__init__.py index 2b8d0ab84..f0d20843f 100644 --- a/scrapy/http/request/__init__.py +++ b/scrapy/http/request/__init__.py @@ -12,10 +12,11 @@ from typing import ( TYPE_CHECKING, Any, AnyStr, + Concatenate, NoReturn, + TypeAlias, TypedDict, TypeVar, - Union, overload, ) @@ -33,13 +34,13 @@ if TYPE_CHECKING: from twisted.python.failure import Failure - # typing.Concatenate requires Python 3.10 # typing.NotRequired and typing.Self require Python 3.11 - from typing_extensions import Concatenate, NotRequired, Self + from typing_extensions import NotRequired, Self + # circular import from scrapy.http import Response - CallbackT = Callable[Concatenate[Response, ...], Any] + CallbackT: TypeAlias = Callable[Concatenate[Response, ...], Any] class VerboseCookie(TypedDict): @@ -50,7 +51,7 @@ class VerboseCookie(TypedDict): secure: NotRequired[bool] -CookiesT = Union[dict[str, str], list[VerboseCookie]] +CookiesT: TypeAlias = dict[str, str] | list[VerboseCookie] RequestTypeVar = TypeVar("RequestTypeVar", bound="Request") diff --git a/scrapy/http/request/form.py b/scrapy/http/request/form.py index 7681419c4..7b2e480c4 100644 --- a/scrapy/http/request/form.py +++ b/scrapy/http/request/form.py @@ -8,16 +8,10 @@ See documentation in docs/topics/request-response.rst from __future__ import annotations from collections.abc import Iterable -from typing import TYPE_CHECKING, Any, Optional, Union, cast +from typing import TYPE_CHECKING, Any, TypeAlias, cast from urllib.parse import urlencode, urljoin, urlsplit, urlunsplit -from lxml.html import ( - FormElement, - InputElement, - MultipleSelectOptions, - SelectElement, - TextareaElement, -) +from parsel.csstranslator import HTMLTranslator from w3lib.html import strip_html5_whitespace from scrapy.http.request import Request @@ -25,14 +19,21 @@ from scrapy.utils.python import is_listlike, to_bytes if TYPE_CHECKING: # typing.Self requires Python 3.11 + from lxml.html import ( + FormElement, + InputElement, + MultipleSelectOptions, + SelectElement, + TextareaElement, + ) from typing_extensions import Self from scrapy.http.response.text import TextResponse -FormdataVType = Union[str, Iterable[str]] -FormdataKVType = tuple[str, FormdataVType] -FormdataType = Optional[Union[dict[str, FormdataVType], list[FormdataKVType]]] +FormdataVType: TypeAlias = str | Iterable[str] +FormdataKVType: TypeAlias = tuple[str, FormdataVType] +FormdataType: TypeAlias = dict[str, FormdataVType] | list[FormdataKVType] | None class FormRequest(Request): @@ -76,8 +77,6 @@ class FormRequest(Request): kwargs.setdefault("encoding", response.encoding) if formcss is not None: - from parsel.csstranslator import HTMLTranslator - formxpath = HTMLTranslator().css_to_xpath(formcss) form = _get_form(response, formname, formid, formnumber, formxpath) @@ -107,7 +106,7 @@ def _urlencode(seq: Iterable[FormdataKVType], enc: str) -> str: values = [ (to_bytes(k, enc), to_bytes(v, enc)) for k, vs in seq - for v in (cast(Iterable[str], vs) if is_listlike(vs) else [cast(str, vs)]) + for v in (cast("Iterable[str]", vs) if is_listlike(vs) else [cast("str", vs)]) ] return urlencode(values, doseq=True) @@ -128,12 +127,12 @@ def _get_form( if formname is not None: f = root.xpath(f'//form[@name="{formname}"]') if f: - return cast(FormElement, f[0]) + return cast("FormElement", f[0]) if formid is not None: f = root.xpath(f'//form[@id="{formid}"]') if f: - return cast(FormElement, f[0]) + return cast("FormElement", f[0]) # Get form element from xpath, if not found, go up if formxpath is not None: @@ -142,7 +141,7 @@ def _get_form( el = nodes[0] while True: if el.tag == "form": - return cast(FormElement, el) + return cast("FormElement", el) el = el.getparent() if el is None: break @@ -153,7 +152,7 @@ def _get_form( form = forms[formnumber] except IndexError: raise IndexError(f"Form number {formnumber} not found in {response}") - return cast(FormElement, form) + return cast("FormElement", form) def _get_inputs( @@ -201,7 +200,7 @@ def _value( n = ele.name v = ele.value if ele.tag == "select": - return _select_value(cast(SelectElement, ele), n, v) + return _select_value(cast("SelectElement", ele), n, v) return n, v @@ -251,7 +250,7 @@ def _get_clickable( except IndexError: pass else: - return (el.get("name"), el.get("value") or "") + return (cast("str", el.get("name")), el.get("value") or "") # We didn't find it, so now we build an XPath expression out of the other # arguments, because they can be used as such diff --git a/scrapy/http/response/text.py b/scrapy/http/response/text.py index 081223882..9c4e4c151 100644 --- a/scrapy/http/response/text.py +++ b/scrapy/http/response/text.py @@ -104,13 +104,14 @@ class TextResponse(Response): @memoizemethod_noargs def _headers_encoding(self) -> str | None: - content_type = cast(bytes, self.headers.get(b"Content-Type", b"")) + content_type = cast("bytes", self.headers.get(b"Content-Type", b"")) return http_content_type_encoding(to_unicode(content_type, encoding="latin-1")) def _body_inferred_encoding(self) -> str: if self._cached_benc is None: content_type = to_unicode( - cast(bytes, self.headers.get(b"Content-Type", b"")), encoding="latin-1" + cast("bytes", self.headers.get(b"Content-Type", b"")), + encoding="latin-1", ) benc, ubody = html_to_unicode( content_type, @@ -141,31 +142,25 @@ class TextResponse(Response): @property def selector(self) -> Selector: - from scrapy.selector import Selector + # circular import + from scrapy.selector import Selector # noqa: PLC0415 if self._cached_selector is None: self._cached_selector = Selector(self) return self._cached_selector def jmespath(self, query: str, **kwargs: Any) -> SelectorList: - from scrapy.selector import SelectorList - if not hasattr(self.selector, "jmespath"): raise AttributeError( "Please install parsel >= 1.8.1 to get jmespath support" ) - - return cast(SelectorList, self.selector.jmespath(query, **kwargs)) + return cast("SelectorList", self.selector.jmespath(query, **kwargs)) def xpath(self, query: str, **kwargs: Any) -> SelectorList: - from scrapy.selector import SelectorList - - return cast(SelectorList, self.selector.xpath(query, **kwargs)) + return cast("SelectorList", self.selector.xpath(query, **kwargs)) def css(self, query: str) -> SelectorList: - from scrapy.selector import SelectorList - - return cast(SelectorList, self.selector.css(query)) + return cast("SelectorList", self.selector.css(query)) def follow( self, diff --git a/scrapy/linkextractors/lxmlhtml.py b/scrapy/linkextractors/lxmlhtml.py index 814e31fec..0ea78e35a 100644 --- a/scrapy/linkextractors/lxmlhtml.py +++ b/scrapy/linkextractors/lxmlhtml.py @@ -9,7 +9,7 @@ import operator import re from collections.abc import Callable, Iterable from functools import partial -from typing import TYPE_CHECKING, Any, Union, cast +from typing import TYPE_CHECKING, Any, TypeAlias, cast from urllib.parse import urljoin, urlparse from lxml import etree @@ -71,12 +71,12 @@ class LxmlParserLinkExtractor: self.scan_tag: Callable[[str], bool] = ( tag if callable(tag) - else cast(Callable[[str], bool], partial(operator.eq, tag)) + else cast("Callable[[str], bool]", partial(operator.eq, tag)) ) self.scan_attr: Callable[[str], bool] = ( attr if callable(attr) - else cast(Callable[[str], bool], partial(operator.eq, attr)) + else cast("Callable[[str], bool]", partial(operator.eq, attr)) ) self.process_attr: Callable[[Any], Any] = ( process if callable(process) else _identity @@ -84,7 +84,7 @@ class LxmlParserLinkExtractor: self.unique: bool = unique self.strip: bool = strip self.link_key: Callable[[Link], str] = ( - cast(Callable[[Link], str], operator.attrgetter("url")) + cast("Callable[[Link], str]", operator.attrgetter("url")) if canonicalized else _canonicalize_link_url ) @@ -157,8 +157,8 @@ class LxmlParserLinkExtractor: return links -_RegexT = Union[str, re.Pattern[str]] -_RegexOrSeveralT = Union[_RegexT, Iterable[_RegexT]] +_Regex: TypeAlias = str | re.Pattern[str] +_RegexOrSeveral: TypeAlias = _Regex | Iterable[_Regex] class LxmlLinkExtractor: @@ -166,8 +166,8 @@ class LxmlLinkExtractor: def __init__( self, - allow: _RegexOrSeveralT = (), - deny: _RegexOrSeveralT = (), + allow: _RegexOrSeveral = (), + deny: _RegexOrSeveral = (), allow_domains: str | Iterable[str] = (), deny_domains: str | Iterable[str] = (), restrict_xpaths: str | Iterable[str] = (), @@ -179,7 +179,7 @@ class LxmlLinkExtractor: deny_extensions: str | Iterable[str] | None = None, restrict_css: str | Iterable[str] = (), strip: bool = True, - restrict_text: _RegexOrSeveralT | None = None, + restrict_text: _RegexOrSeveral | None = None, ): tags, attrs = set(arg_to_iter(tags)), set(arg_to_iter(attrs)) self.link_extractor = LxmlParserLinkExtractor( @@ -208,7 +208,7 @@ class LxmlLinkExtractor: self.restrict_text: list[re.Pattern[str]] = self._compile_regexes(restrict_text) @staticmethod - def _compile_regexes(value: _RegexOrSeveralT | None) -> list[re.Pattern[str]]: + def _compile_regexes(value: _RegexOrSeveral | None) -> list[re.Pattern[str]]: return [ x if isinstance(x, re.Pattern) else re.compile(x) for x in arg_to_iter(value) diff --git a/scrapy/mail.py b/scrapy/mail.py index be2423965..84209effa 100644 --- a/scrapy/mail.py +++ b/scrapy/mail.py @@ -7,7 +7,6 @@ See documentation in docs/topics/email.rst from __future__ import annotations import logging -import warnings from email import encoders as Encoders from email.mime.base import MIMEBase from email.mime.multipart import MIMEMultipart @@ -20,7 +19,6 @@ from typing import IO, TYPE_CHECKING, Any from twisted.internet import ssl from twisted.internet.defer import Deferred -from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.utils.misc import arg_to_iter from scrapy.utils.python import to_bytes @@ -35,7 +33,6 @@ if TYPE_CHECKING: from typing_extensions import Self from scrapy.crawler import Crawler - from scrapy.settings import BaseSettings logger = logging.getLogger(__name__) @@ -73,21 +70,9 @@ class MailSender: self.mailfrom: str = mailfrom self.debug: bool = debug - @classmethod - def from_settings(cls, settings: BaseSettings) -> Self: - warnings.warn( - f"{cls.__name__}.from_settings() is deprecated, use from_crawler() instead.", - category=ScrapyDeprecationWarning, - stacklevel=2, - ) - return cls._from_settings(settings) - @classmethod def from_crawler(cls, crawler: Crawler) -> Self: - return cls._from_settings(crawler.settings) - - @classmethod - def _from_settings(cls, settings: BaseSettings) -> Self: + settings = crawler.settings return cls( smtphost=settings["MAIL_HOST"], mailfrom=settings["MAIL_FROM"], @@ -223,7 +208,8 @@ class MailSender: def _create_sender_factory( self, to_addrs: list[str], msg: IO[bytes], d: Deferred[Any] ) -> ESMTPSenderFactory: - from twisted.mail.smtp import ESMTPSenderFactory + # imports twisted.internet.reactor + from twisted.mail.smtp import ESMTPSenderFactory # noqa: PLC0415 factory_keywords: dict[str, Any] = { "heloFallback": True, diff --git a/scrapy/middleware.py b/scrapy/middleware.py index 2b67dcd21..21a2ebfa3 100644 --- a/scrapy/middleware.py +++ b/scrapy/middleware.py @@ -3,91 +3,112 @@ from __future__ import annotations import logging import pprint import warnings +from abc import ABC, abstractmethod from collections import defaultdict, deque -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar, cast from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning -from scrapy.utils.defer import process_chain, process_parallel +from scrapy.utils.defer import ensure_awaitable +from scrapy.utils.deprecate import argument_is_required from scrapy.utils.misc import build_from_crawler, load_object +from scrapy.utils.python import global_object_name if TYPE_CHECKING: from collections.abc import Callable, Iterable from twisted.internet.defer import Deferred - # typing.Concatenate and typing.ParamSpec require Python 3.10 # typing.Self requires Python 3.11 - from typing_extensions import Concatenate, ParamSpec, Self + from typing_extensions import Self from scrapy import Spider from scrapy.crawler import Crawler - from scrapy.settings import BaseSettings, Settings - - _P = ParamSpec("_P") + from scrapy.settings import Settings logger = logging.getLogger(__name__) _T = TypeVar("_T") -_T2 = TypeVar("_T2") +_P = ParamSpec("_P") -class MiddlewareManager: +class MiddlewareManager(ABC): """Base class for implementing middleware managers""" - component_name = "foo middleware" + component_name: str + _compat_spider: Spider | None = None - def __init__(self, *middlewares: Any) -> None: - self.middlewares = middlewares + def __init__(self, *middlewares: Any, crawler: Crawler | None = None) -> None: + self.crawler: Crawler | None = crawler + if crawler is None: + warnings.warn( + f"MiddlewareManager.__init__() was called without the crawler argument" + f" when creating {global_object_name(self.__class__)}." + f" This is deprecated and the argument will be required in future Scrapy versions.", + category=ScrapyDeprecationWarning, + stacklevel=2, + ) + self.middlewares: tuple[Any, ...] = middlewares # Only process_spider_output and process_spider_exception can be None. # Only process_spider_output can be a tuple, and only until _async compatibility methods are removed. self.methods: dict[str, deque[Callable | tuple[Callable, Callable] | None]] = ( defaultdict(deque) ) + self._mw_methods_requiring_spider: set[Callable] = set() for mw in middlewares: self._add_middleware(mw) + @property + def _spider(self) -> Spider: + if self.crawler is not None: + if self.crawler.spider is None: + raise ValueError( + f"{type(self).__name__} needs to access self.crawler.spider but it is None." + ) + return self.crawler.spider + if self._compat_spider is not None: + return self._compat_spider + raise ValueError(f"{type(self).__name__} has no known Spider instance.") + + def _set_compat_spider(self, spider: Spider | None) -> None: + if spider is None or self.crawler is not None: + return + # printing a deprecation warning is the caller's responsibility + if self._compat_spider is None: + self._compat_spider = spider + elif self._compat_spider is not spider: + raise RuntimeError( + f"Different instances of Spider were passed to {type(self).__name__}:" + f" {self._compat_spider} and {spider}" + ) + + def _warn_spider_arg(self, method_name: str) -> None: + if self.crawler: + msg = ( + f"Passing a spider argument to {type(self).__name__}.{method_name}() is deprecated" + " and the passed value is ignored." + ) + else: + msg = ( + f"Passing a spider argument to {type(self).__name__}.{method_name}() is deprecated," + f" {type(self).__name__} should be instantiated with a Crawler instance instead." + ) + warnings.warn(msg, category=ScrapyDeprecationWarning, stacklevel=3) + @classmethod + @abstractmethod def _get_mwlist_from_settings(cls, settings: Settings) -> list[Any]: raise NotImplementedError - @staticmethod - def _build_from_settings(objcls: type[_T], settings: BaseSettings) -> _T: - if hasattr(objcls, "from_settings"): - instance = objcls.from_settings(settings) # type: ignore[attr-defined] - method_name = "from_settings" - else: - instance = objcls() - method_name = "__new__" - if instance is None: - raise TypeError(f"{objcls.__qualname__}.{method_name} returned None") - return cast(_T, instance) - - @classmethod - def from_settings(cls, settings: Settings, crawler: Crawler | None = None) -> Self: - warnings.warn( - f"{cls.__name__}.from_settings() is deprecated, use from_crawler() instead.", - category=ScrapyDeprecationWarning, - stacklevel=2, - ) - return cls._from_settings(settings, crawler) - @classmethod def from_crawler(cls, crawler: Crawler) -> Self: - return cls._from_settings(crawler.settings, crawler) - - @classmethod - def _from_settings(cls, settings: Settings, crawler: Crawler | None = None) -> Self: - mwlist = cls._get_mwlist_from_settings(settings) + mwlist = cls._get_mwlist_from_settings(crawler.settings) middlewares = [] enabled = [] for clspath in mwlist: try: mwcls = load_object(clspath) - if crawler is not None: - mw = build_from_crawler(mwcls, crawler) - else: - mw = MiddlewareManager._build_from_settings(mwcls, settings) + mw = build_from_crawler(mwcls, crawler) middlewares.append(mw) enabled.append(clspath) except NotConfigured as e: @@ -106,30 +127,51 @@ class MiddlewareManager: }, extra={"crawler": crawler}, ) - return cls(*middlewares) + return cls(*middlewares, crawler=crawler) - def _add_middleware(self, mw: Any) -> None: - if hasattr(mw, "open_spider"): - self.methods["open_spider"].append(mw.open_spider) - if hasattr(mw, "close_spider"): - self.methods["close_spider"].appendleft(mw.close_spider) + def _add_middleware(self, mw: Any) -> None: # noqa: B027 + pass - def _process_parallel( - self, methodname: str, obj: _T, *args: Any - ) -> Deferred[list[_T2]]: - methods = cast( - "Iterable[Callable[Concatenate[_T, _P], _T2]]", self.methods[methodname] - ) - return process_parallel(methods, obj, *args) + def _check_mw_method_spider_arg(self, method: Callable) -> None: + if argument_is_required(method, "spider"): + warnings.warn( + f"{method.__qualname__}() requires a spider argument," + f" this is deprecated and the argument will not be passed in future Scrapy versions." + f" If you need to access the spider instance you can save the crawler instance" + f" passed to from_crawler() and use its spider attribute.", + category=ScrapyDeprecationWarning, + stacklevel=2, + ) + self._mw_methods_requiring_spider.add(method) - def _process_chain(self, methodname: str, obj: _T, *args: Any) -> Deferred[_T]: + async def _process_chain( + self, + methodname: str, + obj: _T, + *args: Any, + add_spider: bool = False, + always_add_spider: bool = False, + ) -> _T: methods = cast( "Iterable[Callable[Concatenate[_T, _P], _T]]", self.methods[methodname] ) - return process_chain(methods, obj, *args) + for method in methods: + if always_add_spider or ( + add_spider and method in self._mw_methods_requiring_spider + ): + obj = await ensure_awaitable(method(obj, *(*args, self._spider))) + else: + obj = await ensure_awaitable(method(obj, *args)) + return obj - def open_spider(self, spider: Spider) -> Deferred[list[None]]: - return self._process_parallel("open_spider", spider) + def open_spider(self, spider: Spider) -> Deferred[list[None]]: # pragma: no cover + raise NotImplementedError( + "MiddlewareManager.open_spider() is no longer implemented" + " and will be removed in a future Scrapy version." + ) - def close_spider(self, spider: Spider) -> Deferred[list[None]]: - return self._process_parallel("close_spider", spider) + def close_spider(self, spider: Spider) -> Deferred[list[None]]: # pragma: no cover + raise NotImplementedError( + "MiddlewareManager.close_spider() is no longer implemented" + " and will be removed in a future Scrapy version." + ) diff --git a/scrapy/pipelines/__init__.py b/scrapy/pipelines/__init__.py index 01f8bd2c8..398e895b8 100644 --- a/scrapy/pipelines/__init__.py +++ b/scrapy/pipelines/__init__.py @@ -6,14 +6,25 @@ See documentation in docs/item-pipeline.rst from __future__ import annotations -from typing import TYPE_CHECKING, Any +import warnings +from typing import TYPE_CHECKING, Any, cast +from twisted.internet.defer import Deferred, DeferredList + +from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.middleware import MiddlewareManager from scrapy.utils.conf import build_component_list -from scrapy.utils.defer import deferred_f_from_coro_f +from scrapy.utils.defer import ( + deferred_from_coro, + maybe_deferred_to_future, + maybeDeferred_coro, +) +from scrapy.utils.python import global_object_name if TYPE_CHECKING: - from twisted.internet.defer import Deferred + from collections.abc import Callable, Iterable + + from twisted.python.failure import Failure from scrapy import Spider from scrapy.settings import Settings @@ -27,11 +38,68 @@ class ItemPipelineManager(MiddlewareManager): return build_component_list(settings.getwithbase("ITEM_PIPELINES")) def _add_middleware(self, pipe: Any) -> None: - super()._add_middleware(pipe) + if hasattr(pipe, "open_spider"): + self.methods["open_spider"].append(pipe.open_spider) + self._check_mw_method_spider_arg(pipe.open_spider) + if hasattr(pipe, "close_spider"): + self.methods["close_spider"].appendleft(pipe.close_spider) + self._check_mw_method_spider_arg(pipe.close_spider) if hasattr(pipe, "process_item"): - self.methods["process_item"].append( - deferred_f_from_coro_f(pipe.process_item) - ) + self.methods["process_item"].append(pipe.process_item) + self._check_mw_method_spider_arg(pipe.process_item) def process_item(self, item: Any, spider: Spider) -> Deferred[Any]: - return self._process_chain("process_item", item, spider) + warnings.warn( + f"{global_object_name(type(self))}.process_item() is deprecated, use process_item_async() instead.", + category=ScrapyDeprecationWarning, + stacklevel=2, + ) + self._set_compat_spider(spider) + return deferred_from_coro(self.process_item_async(item)) + + async def process_item_async(self, item: Any) -> Any: + return await self._process_chain("process_item", item, add_spider=True) + + def _process_parallel(self, methodname: str) -> Deferred[list[None]]: + methods = cast("Iterable[Callable[..., None]]", self.methods[methodname]) + + def get_dfd(method: Callable[..., None]) -> Deferred[None]: + if method in self._mw_methods_requiring_spider: + return maybeDeferred_coro(method, self._spider) + return maybeDeferred_coro(method) + + dfds = [get_dfd(m) for m in methods] + d: Deferred[list[tuple[bool, None]]] = DeferredList( + dfds, fireOnOneErrback=True, consumeErrors=True + ) + d2: Deferred[list[None]] = d.addCallback(lambda r: [x[1] for x in r]) + + def eb(failure: Failure) -> Failure: + return failure.value.subFailure + + d2.addErrback(eb) + return d2 + + def open_spider(self, spider: Spider) -> Deferred[list[None]]: + warnings.warn( + f"{global_object_name(type(self))}.open_spider() is deprecated, use open_spider_async() instead.", + category=ScrapyDeprecationWarning, + stacklevel=2, + ) + self._set_compat_spider(spider) + return self._process_parallel("open_spider") + + async def open_spider_async(self) -> None: + await maybe_deferred_to_future(self._process_parallel("open_spider")) + + def close_spider(self, spider: Spider) -> Deferred[list[None]]: + warnings.warn( + f"{global_object_name(type(self))}.close_spider() is deprecated, use close_spider_async() instead.", + category=ScrapyDeprecationWarning, + stacklevel=2, + ) + self._set_compat_spider(spider) + return self._process_parallel("close_spider") + + async def close_spider_async(self) -> None: + await maybe_deferred_to_future(self._process_parallel("close_spider")) diff --git a/scrapy/pipelines/files.py b/scrapy/pipelines/files.py index 888be81c3..25af0aa72 100644 --- a/scrapy/pipelines/files.py +++ b/scrapy/pipelines/files.py @@ -12,7 +12,6 @@ import hashlib import logging import mimetypes import time -import warnings from collections import defaultdict from contextlib import suppress from ftplib import FTP @@ -25,17 +24,15 @@ from itemadapter import ItemAdapter from twisted.internet.defer import Deferred, maybeDeferred from twisted.internet.threads import deferToThread -from scrapy.exceptions import IgnoreRequest, NotConfigured, ScrapyDeprecationWarning +from scrapy.exceptions import IgnoreRequest, NotConfigured from scrapy.http import Request, Response from scrapy.http.request import NO_CALLBACK from scrapy.pipelines.media import FileInfo, FileInfoOrError, MediaPipeline -from scrapy.settings import BaseSettings, Settings from scrapy.utils.boto import is_botocore_available from scrapy.utils.datatypes import CaseInsensitiveDict -from scrapy.utils.deprecate import method_is_overridden from scrapy.utils.ftp import ftp_store_file from scrapy.utils.log import failure_to_exc_info -from scrapy.utils.python import get_func_args, global_object_name, to_bytes +from scrapy.utils.python import to_bytes from scrapy.utils.request import referer_str if TYPE_CHECKING: @@ -49,6 +46,7 @@ if TYPE_CHECKING: from scrapy import Spider from scrapy.crawler import Crawler + from scrapy.settings import BaseSettings logger = logging.getLogger(__name__) @@ -161,7 +159,7 @@ class S3FilesStore: AWS_USE_SSL = None AWS_VERIFY = None - POLICY = "private" # Overridden from settings.FILES_STORE_S3_ACL in FilesPipeline.from_settings + POLICY = "private" # Overridden from settings.FILES_STORE_S3_ACL in FilesPipeline.from_crawler() HEADERS = { "Cache-Control": "max-age=172800", } @@ -169,7 +167,7 @@ class S3FilesStore: def __init__(self, uri: str): if not is_botocore_available(): raise NotConfigured("missing botocore library") - import botocore.session + import botocore.session # noqa: PLC0415 session = botocore.session.get_session() self.s3_client = session.create_client( @@ -280,11 +278,11 @@ class GCSFilesStore: CACHE_CONTROL = "max-age=172800" # The bucket's default object ACL will be applied to the object. - # Overridden from settings.FILES_STORE_GCS_ACL in FilesPipeline.from_settings. + # Overridden from settings.FILES_STORE_GCS_ACL in FilesPipeline.from_crawler(). POLICY = None def __init__(self, uri: str): - from google.cloud import storage + from google.cloud import storage # noqa: PLC0415 client = storage.Client(project=self.GCS_PROJECT_ID) bucket, prefix = uri[5:].split("/", 1) @@ -317,7 +315,7 @@ class GCSFilesStore: blob_path = self._get_blob_path(path) return cast( - Deferred[StatInfo], + "Deferred[StatInfo]", deferToThread(self.bucket.get_blob, blob_path).addCallback(_onsuccess), ) @@ -446,26 +444,21 @@ class FilesPipeline(MediaPipeline): self, store_uri: str | PathLike[str], download_func: Callable[[Request, Spider], Response] | None = None, - settings: Settings | dict[str, Any] | None = None, *, - crawler: Crawler | None = None, + crawler: Crawler, ): - store_uri = _to_string(store_uri) - if not store_uri: - raise NotConfigured + if not (store_uri and (store_uri := _to_string(store_uri))): + from scrapy.pipelines.images import ImagesPipeline # noqa: PLC0415 - if crawler is not None: - if settings is not None: - warnings.warn( - f"FilesPipeline.__init__() was called with a crawler instance and a settings instance" - f" when creating {global_object_name(self.__class__)}. The settings instance will be ignored" - f" and crawler.settings will be used. The settings argument will be removed in a future Scrapy version.", - category=ScrapyDeprecationWarning, - stacklevel=2, - ) - settings = crawler.settings - elif isinstance(settings, dict) or settings is None: - settings = Settings(settings) + setting_name = ( + "IMAGES_STORE" if isinstance(self, ImagesPipeline) else "FILES_STORE" + ) + raise NotConfigured( + f"{setting_name} setting must be set to a valid path (not empty) " + f"to enable {self.__class__.__name__}." + ) + + settings = crawler.settings cls_name = "FilesPipeline" self.store: FilesStoreProtocol = self._get_store(store_uri) resolve = functools.partial( @@ -483,55 +476,20 @@ class FilesPipeline(MediaPipeline): resolve("FILES_RESULT_FIELD"), self.FILES_RESULT_FIELD ) - super().__init__( - download_func=download_func, - settings=settings if not crawler else None, - crawler=crawler, - ) - - @classmethod - def from_settings(cls, settings: Settings) -> Self: - warnings.warn( - f"{cls.__name__}.from_settings() is deprecated, use from_crawler() instead.", - category=ScrapyDeprecationWarning, - stacklevel=2, - ) - return cls._from_settings(settings, None) + super().__init__(download_func=download_func, crawler=crawler) @classmethod def from_crawler(cls, crawler: Crawler) -> Self: - if method_is_overridden(cls, FilesPipeline, "from_settings"): - warnings.warn( - f"{global_object_name(cls)} overrides FilesPipeline.from_settings()." - f" This method is deprecated and won't be called in future Scrapy versions," - f" please update your code so that it overrides from_crawler() instead.", - category=ScrapyDeprecationWarning, - ) - o = cls.from_settings(crawler.settings) - o._finish_init(crawler) - return o - return cls._from_settings(crawler.settings, crawler) - - @classmethod - def _from_settings(cls, settings: Settings, crawler: Crawler | None) -> Self: + settings = crawler.settings cls._update_stores(settings) store_uri = settings["FILES_STORE"] - if "crawler" in get_func_args(cls.__init__): - o = cls(store_uri, crawler=crawler) - else: - o = cls(store_uri, settings=settings) - if crawler: - o._finish_init(crawler) - warnings.warn( - f"{global_object_name(cls)}.__init__() doesn't take a crawler argument." - " This is deprecated and the argument will be required in future Scrapy versions.", - category=ScrapyDeprecationWarning, - ) - return o + return cls(store_uri, crawler=crawler) @classmethod def _update_stores(cls, settings: BaseSettings) -> None: - s3store: type[S3FilesStore] = cast(type[S3FilesStore], cls.STORE_SCHEMES["s3"]) + s3store: type[S3FilesStore] = cast( + "type[S3FilesStore]", cls.STORE_SCHEMES["s3"] + ) s3store.AWS_ACCESS_KEY_ID = settings["AWS_ACCESS_KEY_ID"] s3store.AWS_SECRET_ACCESS_KEY = settings["AWS_SECRET_ACCESS_KEY"] s3store.AWS_SESSION_TOKEN = settings["AWS_SESSION_TOKEN"] @@ -542,13 +500,13 @@ class FilesPipeline(MediaPipeline): s3store.POLICY = settings["FILES_STORE_S3_ACL"] gcs_store: type[GCSFilesStore] = cast( - type[GCSFilesStore], cls.STORE_SCHEMES["gs"] + "type[GCSFilesStore]", cls.STORE_SCHEMES["gs"] ) gcs_store.GCS_PROJECT_ID = settings["GCS_PROJECT_ID"] gcs_store.POLICY = settings["FILES_STORE_GCS_ACL"] or None ftp_store: type[FTPFilesStore] = cast( - type[FTPFilesStore], cls.STORE_SCHEMES["ftp"] + "type[FTPFilesStore]", cls.STORE_SCHEMES["ftp"] ) ftp_store.FTP_USERNAME = settings["FTP_USER"] ftp_store.FTP_PASSWORD = settings["FTP_PASSWORD"] @@ -562,7 +520,7 @@ class FilesPipeline(MediaPipeline): def media_to_download( self, request: Request, info: MediaPipeline.SpiderInfo, *, item: Any = None - ) -> Deferred[FileInfo | None]: + ) -> Deferred[FileInfo | None] | None: def _onsuccess(result: StatInfo) -> FileInfo | None: if not result: return None # returning None force download @@ -583,7 +541,7 @@ class FilesPipeline(MediaPipeline): {"medianame": self.MEDIA_NAME, "request": request, "referer": referer}, extra={"spider": info.spider}, ) - self.inc_stats(info.spider, "uptodate") + self.inc_stats("uptodate") checksum = result.get("checksum", None) return { @@ -661,7 +619,7 @@ class FilesPipeline(MediaPipeline): {"status": status, "request": request, "referer": referer}, extra={"spider": info.spider}, ) - self.inc_stats(info.spider, status) + self.inc_stats(status) try: path = self.file_path(request, response=response, info=info, item=item) @@ -692,16 +650,20 @@ class FilesPipeline(MediaPipeline): "status": status, } - def inc_stats(self, spider: Spider, status: str) -> None: - assert spider.crawler.stats - spider.crawler.stats.inc_value("file_count", spider=spider) - spider.crawler.stats.inc_value(f"file_status_count/{status}", spider=spider) + def inc_stats(self, status: str) -> None: + assert self.crawler.stats + self.crawler.stats.inc_value("file_count") + self.crawler.stats.inc_value(f"file_status_count/{status}") # Overridable Interface def get_media_requests( self, item: Any, info: MediaPipeline.SpiderInfo ) -> list[Request]: urls = ItemAdapter(item).get(self.files_urls_field, []) + if not isinstance(urls, list): + raise TypeError( + f"{self.files_urls_field} must be a list of URLs, got {type(urls).__name__}. " + ) return [Request(u, callback=NO_CALLBACK) for u in urls] def file_downloaded( @@ -742,5 +704,5 @@ class FilesPipeline(MediaPipeline): media_ext = "" media_type = mimetypes.guess_type(request.url)[0] if media_type: - media_ext = cast(str, mimetypes.guess_extension(media_type)) + media_ext = cast("str", mimetypes.guess_extension(media_type)) return f"full/{media_guid}{media_ext}" diff --git a/scrapy/pipelines/images.py b/scrapy/pipelines/images.py index 63c6908dc..687d29fa5 100644 --- a/scrapy/pipelines/images.py +++ b/scrapy/pipelines/images.py @@ -8,19 +8,17 @@ from __future__ import annotations import functools import hashlib -import warnings from contextlib import suppress from io import BytesIO from typing import TYPE_CHECKING, Any from itemadapter import ItemAdapter -from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning +from scrapy.exceptions import NotConfigured from scrapy.http import Request, Response from scrapy.http.request import NO_CALLBACK from scrapy.pipelines.files import FileException, FilesPipeline, _md5sum -from scrapy.settings import Settings -from scrapy.utils.python import get_func_args, global_object_name, to_bytes +from scrapy.utils.python import to_bytes if TYPE_CHECKING: from collections.abc import Callable, Iterable @@ -58,39 +56,22 @@ class ImagesPipeline(FilesPipeline): self, store_uri: str | PathLike[str], download_func: Callable[[Request, Spider], Response] | None = None, - settings: Settings | dict[str, Any] | None = None, *, - crawler: Crawler | None = None, + crawler: Crawler, ): try: - from PIL import Image + from PIL import Image, ImageOps # noqa: PLC0415 self._Image = Image + self._ImageOps = ImageOps except ImportError: raise NotConfigured( - "ImagesPipeline requires installing Pillow 8.0.0 or later" + "ImagesPipeline requires installing Pillow 8.3.2 or later" ) - super().__init__( - store_uri, - settings=settings if not crawler else None, - download_func=download_func, - crawler=crawler, - ) - - if crawler is not None: - if settings is not None: - warnings.warn( - f"ImagesPipeline.__init__() was called with a crawler instance and a settings instance" - f" when creating {global_object_name(self.__class__)}. The settings instance will be ignored" - f" and crawler.settings will be used. The settings argument will be removed in a future Scrapy version.", - category=ScrapyDeprecationWarning, - stacklevel=2, - ) - settings = crawler.settings - elif isinstance(settings, dict) or settings is None: - settings = Settings(settings) + super().__init__(store_uri, download_func=download_func, crawler=crawler) + settings = crawler.settings resolve = functools.partial( self._key_for_pipe, base_class_name="ImagesPipeline", @@ -120,21 +101,11 @@ class ImagesPipeline(FilesPipeline): ) @classmethod - def _from_settings(cls, settings: Settings, crawler: Crawler | None) -> Self: + def from_crawler(cls, crawler: Crawler) -> Self: + settings = crawler.settings cls._update_stores(settings) store_uri = settings["IMAGES_STORE"] - if "crawler" in get_func_args(cls.__init__): - o = cls(store_uri, crawler=crawler) - else: - o = cls(store_uri, settings=settings) - if crawler: - o._finish_init(crawler) - warnings.warn( - f"{global_object_name(cls)}.__init__() doesn't take a crawler argument." - " This is deprecated and the argument will be required in future Scrapy versions.", - category=ScrapyDeprecationWarning, - ) - return o + return cls(store_uri, crawler=crawler) def file_downloaded( self, @@ -180,8 +151,9 @@ class ImagesPipeline(FilesPipeline): ) -> Iterable[tuple[str, Image.Image, BytesIO]]: path = self.file_path(request, response=response, info=info, item=item) orig_image = self._Image.open(BytesIO(response.body)) + transposed_image = self._ImageOps.exif_transpose(orig_image) - width, height = orig_image.size + width, height = transposed_image.size if width < self.min_width or height < self.min_height: raise ImageException( "Image too small " @@ -190,7 +162,7 @@ class ImagesPipeline(FilesPipeline): ) image, buf = self.convert_image( - orig_image, response_body=BytesIO(response.body) + transposed_image, response_body=BytesIO(response.body) ) yield path, image, buf @@ -241,6 +213,10 @@ class ImagesPipeline(FilesPipeline): self, item: Any, info: MediaPipeline.SpiderInfo ) -> list[Request]: urls = ItemAdapter(item).get(self.images_urls_field, []) + if not isinstance(urls, list): + raise TypeError( + f"{self.images_urls_field} must be a list of URLs, got {type(urls).__name__}. " + ) return [Request(u, callback=NO_CALLBACK) for u in urls] def item_completed( diff --git a/scrapy/pipelines/media.py b/scrapy/pipelines/media.py index e66b86ce6..438e5e7d6 100644 --- a/scrapy/pipelines/media.py +++ b/scrapy/pipelines/media.py @@ -5,24 +5,30 @@ import logging import warnings from abc import ABC, abstractmethod from collections import defaultdict -from typing import TYPE_CHECKING, Any, Literal, NoReturn, TypedDict, Union, cast +from typing import TYPE_CHECKING, Any, Literal, TypeAlias, TypedDict, cast from twisted import version as twisted_version -from twisted.internet.defer import Deferred, DeferredList +from twisted.internet.defer import ( + Deferred, + DeferredList, + inlineCallbacks, + maybeDeferred, +) from twisted.python.failure import Failure from twisted.python.versions import Version from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.http.request import NO_CALLBACK, Request -from scrapy.settings import Settings +from scrapy.utils.asyncio import call_later from scrapy.utils.datatypes import SequenceExclude -from scrapy.utils.defer import defer_result, mustbe_deferred +from scrapy.utils.decorators import _warn_spider_arg +from scrapy.utils.defer import _DEFER_DELAY, _defer_sleep, deferred_from_coro from scrapy.utils.log import failure_to_exc_info from scrapy.utils.misc import arg_to_iter -from scrapy.utils.python import get_func_args, global_object_name +from scrapy.utils.python import global_object_name if TYPE_CHECKING: - from collections.abc import Callable + from collections.abc import Callable, Generator # typing.Self requires Python 3.11 from typing_extensions import Self @@ -30,6 +36,7 @@ if TYPE_CHECKING: from scrapy import Spider from scrapy.crawler import Crawler from scrapy.http import Response + from scrapy.settings import Settings from scrapy.utils.request import RequestFingerprinterProtocol @@ -40,16 +47,14 @@ class FileInfo(TypedDict): status: str -FileInfoOrError = Union[tuple[Literal[True], FileInfo], tuple[Literal[False], Failure]] +FileInfoOrError: TypeAlias = ( + tuple[Literal[True], FileInfo] | tuple[Literal[False], Failure] +) logger = logging.getLogger(__name__) class MediaPipeline(ABC): - crawler: Crawler - _fingerprinter: RequestFingerprinterProtocol - _modern_init = False - LOG_FAILED_RESULTS: bool = True class SpiderInfo: @@ -64,24 +69,17 @@ class MediaPipeline(ABC): def __init__( self, download_func: Callable[[Request, Spider], Response] | None = None, - settings: Settings | dict[str, Any] | None = None, *, - crawler: Crawler | None = None, + crawler: Crawler, ): + self.crawler: Crawler = crawler + assert crawler.request_fingerprinter + self._fingerprinter: RequestFingerprinterProtocol = ( + crawler.request_fingerprinter + ) self.download_func = download_func - if crawler is not None: - if settings is not None: - warnings.warn( - f"MediaPipeline.__init__() was called with a crawler instance and a settings instance" - f" when creating {global_object_name(self.__class__)}. The settings instance will be ignored" - f" and crawler.settings will be used. The settings argument will be removed in a future Scrapy version.", - category=ScrapyDeprecationWarning, - stacklevel=2, - ) - settings = crawler.settings - elif isinstance(settings, dict) or settings is None: - settings = Settings(settings) + settings = crawler.settings resolve = functools.partial( self._key_for_pipe, base_class_name="MediaPipeline", settings=settings ) @@ -90,27 +88,6 @@ class MediaPipeline(ABC): ) self._handle_statuses(self.allow_redirects) - if crawler: - self._finish_init(crawler) - self._modern_init = True - else: - warnings.warn( - f"MediaPipeline.__init__() was called without the crawler argument" - f" when creating {global_object_name(self.__class__)}." - f" This is deprecated and the argument will be required in future Scrapy versions.", - category=ScrapyDeprecationWarning, - stacklevel=2, - ) - - def _finish_init(self, crawler: Crawler) -> None: - # This was done in from_crawler() before 2.12, now it's done in __init__() - # if the crawler was passed to it and may be needed to be called in other - # deprecated code paths explicitly too. After the crawler argument of __init__() - # becomes mandatory this should be inlined there. - self.crawler = crawler - assert crawler.request_fingerprinter - self._fingerprinter = crawler.request_fingerprinter - def _handle_statuses(self, allow_redirects: bool) -> None: self.handle_httpstatus_list = None if allow_redirects: @@ -134,36 +111,16 @@ class MediaPipeline(ABC): @classmethod def from_crawler(cls, crawler: Crawler) -> Self: - pipe: Self - if hasattr(cls, "from_settings"): - pipe = cls.from_settings(crawler.settings) # type: ignore[attr-defined] - warnings.warn( - f"{global_object_name(cls)} has from_settings() and either doesn't have" - " from_crawler() or calls MediaPipeline.from_crawler() from it," - " so from_settings() was used to create the instance of it." - " This is deprecated and calling from_settings() will be removed" - " in a future Scrapy version. Please move the initialization code into" - " from_crawler() or __init__().", - category=ScrapyDeprecationWarning, - ) - elif "crawler" in get_func_args(cls.__init__): - pipe = cls(crawler=crawler) - else: - pipe = cls() - warnings.warn( - f"{global_object_name(cls)}.__init__() doesn't take a crawler argument." - " This is deprecated and the argument will be required in future Scrapy versions.", - category=ScrapyDeprecationWarning, - ) - if not pipe._modern_init: - pipe._finish_init(crawler) - return pipe + return cls(crawler=crawler) - def open_spider(self, spider: Spider) -> None: - self.spiderinfo = self.SpiderInfo(spider) + @_warn_spider_arg + def open_spider(self, spider: Spider | None = None) -> None: + assert self.crawler.spider + self.spiderinfo = self.SpiderInfo(self.crawler.spider) + @_warn_spider_arg def process_item( - self, item: Any, spider: Spider + self, item: Any, spider: Spider | None = None ) -> Deferred[list[FileInfoOrError]]: info = self.spiderinfo requests = arg_to_iter(self.get_media_requests(item, info)) @@ -173,20 +130,25 @@ class MediaPipeline(ABC): ) return dfd.addCallback(self.item_completed, item, info) + @inlineCallbacks def _process_request( self, request: Request, info: SpiderInfo, item: Any - ) -> Deferred[FileInfo]: + ) -> Generator[Deferred[Any], Any, FileInfo]: fp = self._fingerprinter.fingerprint(request) + eb = request.errback request.callback = NO_CALLBACK request.errback = None # Return cached result if request was already seen if fp in info.downloaded: - d = defer_result(info.downloaded[fp]) - if eb: - d.addErrback(eb) - return d + yield _defer_sleep() + cached_result = info.downloaded[fp] + if isinstance(cached_result, Failure): + if eb: + return eb(cached_result) + cached_result.raiseException() + return cached_result # Otherwise, wait for result wad: Deferred[FileInfo] = Deferred() @@ -196,23 +158,27 @@ class MediaPipeline(ABC): # Check if request is downloading right now to avoid doing it twice if fp in info.downloading: - return wad + return (yield wad) # Download request checking media_to_download hook output first info.downloading.add(fp) - dfd: Deferred[FileInfo | None] = mustbe_deferred( - self.media_to_download, request, info, item=item - ) - dfd2: Deferred[FileInfo] = dfd.addCallback( - self._check_media_to_download, request, info, item=item - ) - dfd2.addErrback(self._log_exception) - dfd2.addBoth(self._cache_result_and_execute_waiters, fp, info) - return dfd2.addBoth(lambda _: wad) # it must return wad at last - - def _log_exception(self, result: Failure) -> Failure: - logger.exception(result) - return result + yield _defer_sleep() + result: FileInfo | Failure + try: + file_info = yield maybeDeferred( + self.media_to_download, request, info, item=item + ) + if file_info: + # got a result without downloading + result = file_info + else: + # download the result + result = yield self._check_media_to_download(request, info, item=item) + except Exception: + result = Failure() + logger.exception(result) + self._cache_result_and_execute_waiters(result, fp, info) + return (yield wad) # it must return wad at last def _modify_media_request(self, request: Request) -> None: if self.handle_httpstatus_list: @@ -220,24 +186,31 @@ class MediaPipeline(ABC): else: request.meta["handle_httpstatus_all"] = True - def _check_media_to_download( - self, result: FileInfo | None, request: Request, info: SpiderInfo, item: Any - ) -> FileInfo | Deferred[FileInfo]: - if result is not None: - return result - dfd: Deferred[Response] - if self.download_func: - # this ugly code was left only to support tests. TODO: remove - dfd = mustbe_deferred(self.download_func, request, info.spider) - else: - self._modify_media_request(request) - assert self.crawler.engine - dfd = self.crawler.engine.download(request) - dfd2: Deferred[FileInfo] = dfd.addCallback( - self.media_downloaded, request, info, item=item - ) - dfd2.addErrback(self.media_failed, request, info) - return dfd2 + @inlineCallbacks + def _check_media_to_download( # pylint: disable=inconsistent-return-statements + self, request: Request, info: SpiderInfo, item: Any + ) -> Generator[Deferred[Any], Any, FileInfo]: + try: + if self.download_func: + # this ugly code was left only to support tests. TODO: remove + response = yield maybeDeferred(self.download_func, request, info.spider) + else: + self._modify_media_request(request) + assert self.crawler.engine + response = yield deferred_from_coro( + self.crawler.engine.download_async(request) + ) + return self.media_downloaded(response, request, info, item=item) + except Exception: + failure = self.media_failed(Failure(), request, info) + if isinstance(failure, Failure): + warnings.warn( + f"{global_object_name(self.media_failed)} returned a Failure instance." + f" This is deprecated, please raise an exception instead, e.g. via failure.raiseException().", + category=ScrapyDeprecationWarning, + stacklevel=2, + ) + failure.raiseException() def _cache_result_and_execute_waiters( self, result: FileInfo | Failure, fp: bytes, info: SpiderInfo @@ -273,13 +246,16 @@ class MediaPipeline(ABC): info.downloading.remove(fp) info.downloaded[fp] = result # cache result for wad in info.waiting.pop(fp): - defer_result(result).chainDeferred(wad) + if isinstance(result, Failure): + call_later(_DEFER_DELAY, wad.errback, result) + else: + call_later(_DEFER_DELAY, wad.callback, result) # Overridable Interface @abstractmethod def media_to_download( self, request: Request, info: SpiderInfo, *, item: Any = None - ) -> Deferred[FileInfo | None]: + ) -> Deferred[FileInfo | None] | None: """Check request before starting download""" raise NotImplementedError @@ -303,7 +279,7 @@ class MediaPipeline(ABC): @abstractmethod def media_failed( self, failure: Failure, request: Request, info: SpiderInfo - ) -> NoReturn: + ) -> Failure: """Handler for failed downloads""" raise NotImplementedError diff --git a/scrapy/pqueues.py b/scrapy/pqueues.py index 34b235d83..42c53a527 100644 --- a/scrapy/pqueues.py +++ b/scrapy/pqueues.py @@ -4,7 +4,6 @@ import hashlib import logging from typing import TYPE_CHECKING, Protocol, cast -from scrapy import Request from scrapy.utils.misc import build_from_crawler if TYPE_CHECKING: @@ -13,6 +12,7 @@ if TYPE_CHECKING: # typing.Self requires Python 3.11 from typing_extensions import Self + from scrapy import Request from scrapy.core.downloader import Downloader from scrapy.crawler import Crawler @@ -211,7 +211,7 @@ class ScrapyPriorityQueue: except KeyError: queue = self.queues[self.curprio] # Protocols can't declare optional members - return cast(Request, queue.peek()) # type: ignore[attr-defined] + return cast("Request", queue.peek()) # type: ignore[attr-defined] def close(self) -> list[int]: active: set[int] = set() diff --git a/scrapy/robotstxt.py b/scrapy/robotstxt.py index 417c9c142..18b622546 100644 --- a/scrapy/robotstxt.py +++ b/scrapy/robotstxt.py @@ -4,6 +4,9 @@ import logging import sys from abc import ABCMeta, abstractmethod from typing import TYPE_CHECKING +from urllib.robotparser import RobotFileParser + +from protego import Protego from scrapy.utils.python import to_unicode @@ -25,7 +28,7 @@ def decode_robotstxt( if to_native_str_type: body_decoded = to_unicode(robotstxt_body) else: - body_decoded = robotstxt_body.decode("utf-8", errors="ignore") + body_decoded = robotstxt_body.decode("utf-8-sig", errors="ignore") except UnicodeDecodeError: # If we found garbage or robots.txt in an encoding other than UTF-8, disregard it. # Switch to 'allow all' state. @@ -67,8 +70,6 @@ class RobotParser(metaclass=ABCMeta): class PythonRobotParser(RobotParser): def __init__(self, robotstxt_body: bytes, spider: Spider | None): - from urllib.robotparser import RobotFileParser - self.spider: Spider | None = spider body_decoded = decode_robotstxt(robotstxt_body, spider, to_native_str_type=True) self.rp: RobotFileParser = RobotFileParser() @@ -87,7 +88,7 @@ class PythonRobotParser(RobotParser): class RerpRobotParser(RobotParser): def __init__(self, robotstxt_body: bytes, spider: Spider | None): - from robotexclusionrulesparser import RobotExclusionRulesParser + from robotexclusionrulesparser import RobotExclusionRulesParser # noqa: PLC0415 self.spider: Spider | None = spider self.rp: RobotExclusionRulesParser = RobotExclusionRulesParser() @@ -107,8 +108,6 @@ class RerpRobotParser(RobotParser): class ProtegoRobotParser(RobotParser): def __init__(self, robotstxt_body: bytes, spider: Spider | None): - from protego import Protego - self.spider: Spider | None = spider body_decoded = decode_robotstxt(robotstxt_body, spider) self.rp = Protego.parse(body_decoded) diff --git a/scrapy/selector/unified.py b/scrapy/selector/unified.py index f8365a87b..99b22aca9 100644 --- a/scrapy/selector/unified.py +++ b/scrapy/selector/unified.py @@ -75,7 +75,7 @@ class Selector(_ParselSelector, object_ref): self, response: TextResponse | None = None, text: str | None = None, - type: str | None = None, + type: str | None = None, # noqa: A002 root: Any | None = _NOT_SET, **kwargs: Any, ): diff --git a/scrapy/settings/__init__.py b/scrapy/settings/__init__.py index cc4853c8f..c330c4c35 100644 --- a/scrapy/settings/__init__.py +++ b/scrapy/settings/__init__.py @@ -2,17 +2,19 @@ from __future__ import annotations import copy import json +import warnings from collections.abc import Iterable, Iterator, Mapping, MutableMapping from importlib import import_module from pprint import pformat -from typing import TYPE_CHECKING, Any, Union, cast +from typing import TYPE_CHECKING, Any, TypeAlias, cast +from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.settings import default_settings from scrapy.utils.misc import load_object # The key types are restricted in BaseSettings._get_key() to ones supported by JSON, # see https://github.com/scrapy/scrapy/issues/5383. -_SettingsKeyT = Union[bool, float, int, str, None] +_SettingsKey: TypeAlias = bool | float | int | str | None if TYPE_CHECKING: from types import ModuleType @@ -23,7 +25,7 @@ if TYPE_CHECKING: # typing.Self requires Python 3.11 from typing_extensions import Self - _SettingsInputT = Union[SupportsItems[_SettingsKeyT, Any], str, None] + _SettingsInput: TypeAlias = SupportsItems[_SettingsKey, Any] | str | None SETTINGS_PRIORITIES: dict[str, int] = { @@ -74,7 +76,7 @@ class SettingsAttribute: return f"" -class BaseSettings(MutableMapping[_SettingsKeyT, Any]): +class BaseSettings(MutableMapping[_SettingsKey, Any]): """ Instances of this class behave like dictionaries, but store priorities along with their ``(key, value)`` pairs, and can be frozen (i.e. marked @@ -98,13 +100,13 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]): __default = object() - def __init__(self, values: _SettingsInputT = None, priority: int | str = "project"): + def __init__(self, values: _SettingsInput = None, priority: int | str = "project"): self.frozen: bool = False - self.attributes: dict[_SettingsKeyT, SettingsAttribute] = {} + self.attributes: dict[_SettingsKey, SettingsAttribute] = {} if values: self.update(values, priority) - def __getitem__(self, opt_name: _SettingsKeyT) -> Any: + def __getitem__(self, opt_name: _SettingsKey) -> Any: if opt_name not in self: return None return self.attributes[opt_name].value @@ -112,7 +114,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]): def __contains__(self, name: Any) -> bool: return name in self.attributes - def add_to_list(self, name: _SettingsKeyT, item: Any) -> None: + def add_to_list(self, name: _SettingsKey, item: Any) -> None: """Append *item* to the :class:`list` setting with the specified *name* if *item* is not already in that list. @@ -123,7 +125,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]): if item not in value: self.set(name, [*value, item], self.getpriority(name) or 0) - def remove_from_list(self, name: _SettingsKeyT, item: Any) -> None: + def remove_from_list(self, name: _SettingsKey, item: Any) -> None: """Remove *item* from the :class:`list` setting with the specified *name*. @@ -137,7 +139,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]): raise ValueError(f"{item!r} not found in the {name} setting ({value!r}).") self.set(name, [v for v in value if v != item], self.getpriority(name) or 0) - def get(self, name: _SettingsKeyT, default: Any = None) -> Any: + def get(self, name: _SettingsKey, default: Any = None) -> Any: """ Get a setting value without affecting its original type. @@ -147,9 +149,18 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]): :param default: the value to return if no setting is found :type default: object """ + if name == "CONCURRENT_REQUESTS_PER_IP" and ( + isinstance(self[name], int) and self[name] != 0 + ): + warnings.warn( + "The CONCURRENT_REQUESTS_PER_IP setting is deprecated, use CONCURRENT_REQUESTS_PER_DOMAIN instead.", + ScrapyDeprecationWarning, + stacklevel=2, + ) + return self[name] if self[name] is not None else default - def getbool(self, name: _SettingsKeyT, default: bool = False) -> bool: + def getbool(self, name: _SettingsKey, default: bool = False) -> bool: """ Get a setting value as a boolean. @@ -179,7 +190,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]): "'True'/'False' and 'true'/'false'" ) - def getint(self, name: _SettingsKeyT, default: int = 0) -> int: + def getint(self, name: _SettingsKey, default: int = 0) -> int: """ Get a setting value as an int. @@ -191,7 +202,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]): """ return int(self.get(name, default)) - def getfloat(self, name: _SettingsKeyT, default: float = 0.0) -> float: + def getfloat(self, name: _SettingsKey, default: float = 0.0) -> float: """ Get a setting value as a float. @@ -204,7 +215,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]): return float(self.get(name, default)) def getlist( - self, name: _SettingsKeyT, default: list[Any] | None = None + self, name: _SettingsKey, default: list[Any] | None = None ) -> list[Any]: """ Get a setting value as a list. If the setting original type is a list, @@ -228,7 +239,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]): return list(value) def getdict( - self, name: _SettingsKeyT, default: dict[Any, Any] | None = None + self, name: _SettingsKey, default: dict[Any, Any] | None = None ) -> dict[Any, Any]: """ Get a setting value as a dictionary. If the setting original type is a @@ -252,7 +263,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]): def getdictorlist( self, - name: _SettingsKeyT, + name: _SettingsKey, default: dict[Any, Any] | list[Any] | tuple[Any] | None = None, ) -> dict[Any, Any] | list[Any]: """Get a setting value as either a :class:`dict` or a :class:`list`. @@ -282,16 +293,24 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]): if isinstance(value, str): try: value_loaded = json.loads(value) - assert isinstance(value_loaded, (dict, list)) + if not isinstance(value_loaded, (dict, list)): + raise ValueError( + f"JSON string for setting '{name}' must evaluate to a dict or list, " + f"got {type(value_loaded).__name__}: {value_loaded!r}" + ) return value_loaded except ValueError: return value.split(",") if isinstance(value, tuple): return list(value) - assert isinstance(value, (dict, list)) + if not isinstance(value, (dict, list)): + raise ValueError( + f"Setting '{name}' must be a dict, list, tuple, or string, " + f"got {type(value).__name__}: {value!r}" + ) return copy.deepcopy(value) - def getwithbase(self, name: _SettingsKeyT) -> BaseSettings: + def getwithbase(self, name: _SettingsKey) -> BaseSettings: """Get a composition of a dictionary-like setting and its `_BASE` counterpart. @@ -305,7 +324,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]): compbs.update(self[name]) return compbs - def getpriority(self, name: _SettingsKeyT) -> int | None: + def getpriority(self, name: _SettingsKey) -> int | None: """ Return the current numerical priority value of a setting, or ``None`` if the given ``name`` does not exist. @@ -325,12 +344,12 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]): stored. """ if len(self) > 0: - return max(cast(int, self.getpriority(name)) for name in self) + return max(cast("int", self.getpriority(name)) for name in self) return get_settings_priority("default") def replace_in_component_priority_dict( self, - name: _SettingsKeyT, + name: _SettingsKey, old_cls: type, new_cls: type, priority: int | None = None, @@ -369,11 +388,11 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]): ) self.set(name, component_priority_dict, priority=self.getpriority(name) or 0) - def __setitem__(self, name: _SettingsKeyT, value: Any) -> None: + def __setitem__(self, name: _SettingsKey, value: Any) -> None: self.set(name, value) def set( - self, name: _SettingsKeyT, value: Any, priority: int | str = "project" + self, name: _SettingsKey, value: Any, priority: int | str = "project" ) -> None: """ Store a key/value attribute with a given priority. @@ -403,7 +422,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]): self.attributes[name].set(value, priority) def set_in_component_priority_dict( - self, name: _SettingsKeyT, cls: type, priority: int | None + self, name: _SettingsKey, cls: type, priority: int | None ) -> None: """Set the *cls* component in the *name* :ref:`component priority dictionary ` setting with *priority*. @@ -428,7 +447,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]): def setdefault( self, - name: _SettingsKeyT, + name: _SettingsKey, default: Any = None, priority: int | str = "project", ) -> Any: @@ -439,7 +458,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]): return self.attributes[name].value def setdefault_in_component_priority_dict( - self, name: _SettingsKeyT, cls: type, priority: int | None + self, name: _SettingsKey, cls: type, priority: int | None ) -> None: """Set the *cls* component in the *name* :ref:`component priority dictionary ` setting with *priority* @@ -456,7 +475,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]): component_priority_dict[cls] = priority self.set(name, component_priority_dict, self.getpriority(name) or 0) - def setdict(self, values: _SettingsInputT, priority: int | str = "project") -> None: + def setdict(self, values: _SettingsInput, priority: int | str = "project") -> None: self.update(values, priority) def setmodule( @@ -484,7 +503,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]): self.set(key, getattr(module, key), priority) # BaseSettings.update() doesn't support all inputs that MutableMapping.update() supports - def update(self, values: _SettingsInputT, priority: int | str = "project") -> None: # type: ignore[override] + def update(self, values: _SettingsInput, priority: int | str = "project") -> None: # type: ignore[override] """ Store key/value pairs with a given priority. @@ -508,24 +527,24 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]): """ self._assert_mutability() if isinstance(values, str): - values = cast(dict[_SettingsKeyT, Any], json.loads(values)) + values = cast("dict[_SettingsKey, Any]", json.loads(values)) if values is not None: if isinstance(values, BaseSettings): for name, value in values.items(): - self.set(name, value, cast(int, values.getpriority(name))) + self.set(name, value, cast("int", values.getpriority(name))) else: for name, value in values.items(): self.set(name, value, priority) - def delete(self, name: _SettingsKeyT, priority: int | str = "project") -> None: + def delete(self, name: _SettingsKey, priority: int | str = "project") -> None: if name not in self: raise KeyError(name) self._assert_mutability() priority = get_settings_priority(priority) - if priority >= cast(int, self.getpriority(name)): + if priority >= cast("int", self.getpriority(name)): del self.attributes[name] - def __delitem__(self, name: _SettingsKeyT) -> None: + def __delitem__(self, name: _SettingsKey) -> None: self._assert_mutability() del self.attributes[name] @@ -565,26 +584,26 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]): copy.freeze() return copy - def __iter__(self) -> Iterator[_SettingsKeyT]: + def __iter__(self) -> Iterator[_SettingsKey]: return iter(self.attributes) def __len__(self) -> int: return len(self.attributes) - def _to_dict(self) -> dict[_SettingsKeyT, Any]: + def _to_dict(self) -> dict[_SettingsKey, Any]: return { self._get_key(k): (v._to_dict() if isinstance(v, BaseSettings) else v) for k, v in self.items() } - def _get_key(self, key_value: Any) -> _SettingsKeyT: + def _get_key(self, key_value: Any) -> _SettingsKey: return ( key_value if isinstance(key_value, (bool, float, int, str, type(None))) else str(key_value) ) - def copy_to_dict(self) -> dict[_SettingsKeyT, Any]: + def copy_to_dict(self) -> dict[_SettingsKey, Any]: """ Make a copy of current settings and convert to a dict. @@ -607,7 +626,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]): else: p.text(pformat(self.copy_to_dict())) - def pop(self, name: _SettingsKeyT, default: Any = __default) -> Any: + def pop(self, name: _SettingsKey, default: Any = __default) -> Any: try: value = self.attributes[name].value except KeyError: @@ -629,7 +648,7 @@ class Settings(BaseSettings): described on :ref:`topics-settings-ref` already populated. """ - def __init__(self, values: _SettingsInputT = None, priority: int | str = "project"): + def __init__(self, values: _SettingsInput = None, priority: int | str = "project"): # Do not pass kwarg values here. We don't want to promote user-defined # dicts, and we want to update, not replace, default dicts with the # values given by the user @@ -651,7 +670,7 @@ def iter_default_settings() -> Iterable[tuple[str, Any]]: def overridden_settings( - settings: Mapping[_SettingsKeyT, Any], + settings: Mapping[_SettingsKey, Any], ) -> Iterable[tuple[str, Any]]: """Return an iterable of the settings that have been overridden""" for name, defvalue in iter_default_settings(): diff --git a/scrapy/settings/default_settings.py b/scrapy/settings/default_settings.py index b6f47f1c3..e66f78c47 100644 --- a/scrapy/settings/default_settings.py +++ b/scrapy/settings/default_settings.py @@ -17,6 +17,179 @@ import sys from importlib import import_module from pathlib import Path +__all__ = [ + "ADDONS", + "AJAXCRAWL_ENABLED", + "AJAXCRAWL_MAXSIZE", + "ASYNCIO_EVENT_LOOP", + "AUTOTHROTTLE_DEBUG", + "AUTOTHROTTLE_ENABLED", + "AUTOTHROTTLE_MAX_DELAY", + "AUTOTHROTTLE_START_DELAY", + "AUTOTHROTTLE_TARGET_CONCURRENCY", + "BOT_NAME", + "CLOSESPIDER_ERRORCOUNT", + "CLOSESPIDER_ITEMCOUNT", + "CLOSESPIDER_PAGECOUNT", + "CLOSESPIDER_TIMEOUT", + "COMMANDS_MODULE", + "COMPRESSION_ENABLED", + "CONCURRENT_ITEMS", + "CONCURRENT_REQUESTS", + "CONCURRENT_REQUESTS_PER_DOMAIN", + "COOKIES_DEBUG", + "COOKIES_ENABLED", + "CRAWLSPIDER_FOLLOW_LINKS", + "DEFAULT_DROPITEM_LOG_LEVEL", + "DEFAULT_ITEM_CLASS", + "DEFAULT_REQUEST_HEADERS", + "DEPTH_LIMIT", + "DEPTH_PRIORITY", + "DEPTH_STATS_VERBOSE", + "DNSCACHE_ENABLED", + "DNSCACHE_SIZE", + "DNS_RESOLVER", + "DNS_TIMEOUT", + "DOWNLOADER", + "DOWNLOADER_CLIENTCONTEXTFACTORY", + "DOWNLOADER_CLIENT_TLS_CIPHERS", + "DOWNLOADER_CLIENT_TLS_METHOD", + "DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING", + "DOWNLOADER_HTTPCLIENTFACTORY", + "DOWNLOADER_MIDDLEWARES", + "DOWNLOADER_MIDDLEWARES_BASE", + "DOWNLOADER_STATS", + "DOWNLOAD_DELAY", + "DOWNLOAD_FAIL_ON_DATALOSS", + "DOWNLOAD_HANDLERS", + "DOWNLOAD_HANDLERS_BASE", + "DOWNLOAD_MAXSIZE", + "DOWNLOAD_TIMEOUT", + "DOWNLOAD_WARNSIZE", + "DUPEFILTER_CLASS", + "EDITOR", + "EXTENSIONS", + "EXTENSIONS_BASE", + "FEEDS", + "FEED_EXPORTERS", + "FEED_EXPORTERS_BASE", + "FEED_EXPORT_BATCH_ITEM_COUNT", + "FEED_EXPORT_ENCODING", + "FEED_EXPORT_FIELDS", + "FEED_EXPORT_INDENT", + "FEED_FORMAT", + "FEED_STORAGES", + "FEED_STORAGES_BASE", + "FEED_STORAGE_FTP_ACTIVE", + "FEED_STORAGE_GCS_ACL", + "FEED_STORAGE_S3_ACL", + "FEED_STORE_EMPTY", + "FEED_TEMPDIR", + "FEED_URI_PARAMS", + "FILES_STORE_GCS_ACL", + "FILES_STORE_S3_ACL", + "FORCE_CRAWLER_PROCESS", + "FTP_PASSIVE_MODE", + "FTP_PASSWORD", + "FTP_USER", + "GCS_PROJECT_ID", + "HTTPCACHE_ALWAYS_STORE", + "HTTPCACHE_DBM_MODULE", + "HTTPCACHE_DIR", + "HTTPCACHE_ENABLED", + "HTTPCACHE_EXPIRATION_SECS", + "HTTPCACHE_GZIP", + "HTTPCACHE_IGNORE_HTTP_CODES", + "HTTPCACHE_IGNORE_MISSING", + "HTTPCACHE_IGNORE_RESPONSE_CACHE_CONTROLS", + "HTTPCACHE_IGNORE_SCHEMES", + "HTTPCACHE_POLICY", + "HTTPCACHE_STORAGE", + "HTTPPROXY_AUTH_ENCODING", + "HTTPPROXY_ENABLED", + "IMAGES_STORE_GCS_ACL", + "IMAGES_STORE_S3_ACL", + "ITEM_PIPELINES", + "ITEM_PIPELINES_BASE", + "ITEM_PROCESSOR", + "JOBDIR", + "LOGSTATS_INTERVAL", + "LOG_DATEFORMAT", + "LOG_ENABLED", + "LOG_ENCODING", + "LOG_FILE", + "LOG_FILE_APPEND", + "LOG_FORMAT", + "LOG_FORMATTER", + "LOG_LEVEL", + "LOG_SHORT_NAMES", + "LOG_STDOUT", + "LOG_VERSIONS", + "MAIL_FROM", + "MAIL_HOST", + "MAIL_PASS", + "MAIL_PORT", + "MAIL_USER", + "MEMDEBUG_ENABLED", + "MEMDEBUG_NOTIFY", + "MEMUSAGE_CHECK_INTERVAL_SECONDS", + "MEMUSAGE_ENABLED", + "MEMUSAGE_LIMIT_MB", + "MEMUSAGE_NOTIFY_MAIL", + "MEMUSAGE_WARNING_MB", + "METAREFRESH_ENABLED", + "METAREFRESH_IGNORE_TAGS", + "METAREFRESH_MAXDELAY", + "NEWSPIDER_MODULE", + "PERIODIC_LOG_DELTA", + "PERIODIC_LOG_STATS", + "PERIODIC_LOG_TIMING_ENABLED", + "RANDOMIZE_DOWNLOAD_DELAY", + "REACTOR_THREADPOOL_MAXSIZE", + "REDIRECT_ENABLED", + "REDIRECT_MAX_TIMES", + "REDIRECT_PRIORITY_ADJUST", + "REFERER_ENABLED", + "REFERRER_POLICY", + "REQUEST_FINGERPRINTER_CLASS", + "RETRY_ENABLED", + "RETRY_EXCEPTIONS", + "RETRY_HTTP_CODES", + "RETRY_PRIORITY_ADJUST", + "RETRY_TIMES", + "ROBOTSTXT_OBEY", + "ROBOTSTXT_PARSER", + "ROBOTSTXT_USER_AGENT", + "SCHEDULER", + "SCHEDULER_DEBUG", + "SCHEDULER_DISK_QUEUE", + "SCHEDULER_MEMORY_QUEUE", + "SCHEDULER_PRIORITY_QUEUE", + "SCHEDULER_START_DISK_QUEUE", + "SCHEDULER_START_MEMORY_QUEUE", + "SCRAPER_SLOT_MAX_ACTIVE_SIZE", + "SPIDER_CONTRACTS", + "SPIDER_CONTRACTS_BASE", + "SPIDER_LOADER_CLASS", + "SPIDER_LOADER_WARN_ONLY", + "SPIDER_MIDDLEWARES", + "SPIDER_MIDDLEWARES_BASE", + "SPIDER_MODULES", + "STATSMAILER_RCPTS", + "STATS_CLASS", + "STATS_DUMP", + "TELNETCONSOLE_ENABLED", + "TELNETCONSOLE_HOST", + "TELNETCONSOLE_PASSWORD", + "TELNETCONSOLE_PORT", + "TELNETCONSOLE_USERNAME", + "TEMPLATES_DIR", + "TWISTED_REACTOR", + "URLLENGTH_LIMIT", + "USER_AGENT", + "WARN_ON_GENERATOR_RETURN_VALUE", +] + ADDONS = {} AJAXCRAWL_ENABLED = False @@ -45,7 +218,6 @@ CONCURRENT_ITEMS = 100 CONCURRENT_REQUESTS = 16 CONCURRENT_REQUESTS_PER_DOMAIN = 8 -CONCURRENT_REQUESTS_PER_IP = 0 COOKIES_ENABLED = True COOKIES_DEBUG = False @@ -78,8 +250,8 @@ DOWNLOAD_HANDLERS = {} DOWNLOAD_HANDLERS_BASE = { "data": "scrapy.core.downloader.handlers.datauri.DataURIDownloadHandler", "file": "scrapy.core.downloader.handlers.file.FileDownloadHandler", - "http": "scrapy.core.downloader.handlers.http.HTTPDownloadHandler", - "https": "scrapy.core.downloader.handlers.http.HTTPDownloadHandler", + "http": "scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler", + "https": "scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler", "s3": "scrapy.core.downloader.handlers.s3.S3DownloadHandler", "ftp": "scrapy.core.downloader.handlers.ftp.FTPDownloadHandler", } @@ -135,6 +307,7 @@ if sys.platform == "win32": EXTENSIONS = {} EXTENSIONS_BASE = { "scrapy.extensions.corestats.CoreStats": 0, + "scrapy.extensions.logcount.LogCount": 0, "scrapy.extensions.telnet.TelnetConsole": 0, "scrapy.extensions.memusage.MemoryUsage": 0, "scrapy.extensions.memdebug.MemoryDebugger": 0, @@ -277,7 +450,6 @@ REFERER_ENABLED = True REFERRER_POLICY = "scrapy.spidermiddlewares.referer.DefaultReferrerPolicy" REQUEST_FINGERPRINTER_CLASS = "scrapy.utils.request.RequestFingerprinter" -REQUEST_FINGERPRINTER_IMPLEMENTATION = "SENTINEL" RETRY_ENABLED = True RETRY_EXCEPTIONS = [ @@ -358,3 +530,19 @@ URLLENGTH_LIMIT = 2083 USER_AGENT = f"Scrapy/{import_module('scrapy').__version__} (+https://scrapy.org)" WARN_ON_GENERATOR_RETURN_VALUE = True + + +def __getattr__(name: str): + if name == "CONCURRENT_REQUESTS_PER_IP": + import warnings # noqa: PLC0415 + + from scrapy.exceptions import ScrapyDeprecationWarning # noqa: PLC0415 + + warnings.warn( + "The scrapy.settings.default_settings.CONCURRENT_REQUESTS_PER_IP attribute is deprecated, use scrapy.settings.default_settings.CONCURRENT_REQUESTS_PER_DOMAIN instead.", + ScrapyDeprecationWarning, + stacklevel=2, + ) + return 0 + + raise AttributeError diff --git a/scrapy/shell.py b/scrapy/shell.py index bb39eccc3..4b2bdf6cf 100644 --- a/scrapy/shell.py +++ b/scrapy/shell.py @@ -16,6 +16,7 @@ from twisted.internet import defer, threads from twisted.python import threadable from w3lib.url import any_to_uri +import scrapy from scrapy.crawler import Crawler from scrapy.exceptions import IgnoreRequest from scrapy.http import Request, Response @@ -24,7 +25,7 @@ from scrapy.spiders import Spider from scrapy.utils.conf import get_config from scrapy.utils.console import DEFAULT_PYTHON_SHELLS, start_python_console from scrapy.utils.datatypes import SequenceExclude -from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future +from scrapy.utils.defer import _schedule_coro, deferred_f_from_coro_f from scrapy.utils.misc import load_object from scrapy.utils.reactor import is_asyncio_reactor_installed, set_asyncio_event_loop from scrapy.utils.response import open_in_browser @@ -125,10 +126,8 @@ class Shell: self.crawler.spider = spider assert self.crawler.engine - await maybe_deferred_to_future( - self.crawler.engine.open_spider(spider, close_if_idle=False) - ) - self.crawler.engine._start_request_processing() + await self.crawler.engine.open_spider_async(close_if_idle=False) + _schedule_coro(self.crawler.engine._start_request_processing()) self.spider = spider def fetch( @@ -164,8 +163,6 @@ class Shell: request: Request | None = None, spider: Spider | None = None, ) -> None: - import scrapy - self.vars["scrapy"] = scrapy self.vars["crawler"] = self.crawler self.vars["item"] = self.item_class() @@ -206,7 +203,7 @@ class Shell: b.append(" shelp() Shell help (print this help)") b.append(" view(response) View response in a browser") - return "\n".join(f"[s] {line}" for line in b) + return "\n".join(f"[s] {line}" for line in b) + "\n" def _is_relevant(self, value: Any) -> bool: return isinstance(value, self.relevant_classes) or is_item(value) diff --git a/scrapy/signalmanager.py b/scrapy/signalmanager.py index 283060074..347eddfdb 100644 --- a/scrapy/signalmanager.py +++ b/scrapy/signalmanager.py @@ -77,6 +77,8 @@ class SignalManager: The keyword arguments are passed to the signal handlers (connected through the :meth:`connect` method). + + .. versionadded:: VERSION """ kwargs.setdefault("sender", self.sender) return await _signal.send_catch_log_async(signal, **kwargs) diff --git a/scrapy/spidermiddlewares/base.py b/scrapy/spidermiddlewares/base.py index 196b84d0d..889fc6df1 100644 --- a/scrapy/spidermiddlewares/base.py +++ b/scrapy/spidermiddlewares/base.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any from scrapy import Request, Spider +from scrapy.utils.decorators import _warn_spider_arg if TYPE_CHECKING: from collections.abc import AsyncIterator, Iterable @@ -52,15 +53,20 @@ class BaseSpiderMiddleware: if (o := self._get_processed(o, None)) is not None: yield o + @_warn_spider_arg def process_spider_output( - self, response: Response, result: Iterable[Any], spider: Spider + self, response: Response, result: Iterable[Any], spider: Spider | None = None ) -> Iterable[Any]: for o in result: if (o := self._get_processed(o, response)) is not None: yield o + @_warn_spider_arg async def process_spider_output_async( - self, response: Response, result: AsyncIterator[Any], spider: Spider + self, + response: Response, + result: AsyncIterator[Any], + spider: Spider | None = None, ) -> AsyncIterator[Any]: async for o in result: if (o := self._get_processed(o, response)) is not None: diff --git a/scrapy/spidermiddlewares/depth.py b/scrapy/spidermiddlewares/depth.py index 6b115ebe6..9c387530c 100644 --- a/scrapy/spidermiddlewares/depth.py +++ b/scrapy/spidermiddlewares/depth.py @@ -10,6 +10,7 @@ import logging from typing import TYPE_CHECKING, Any from scrapy.spidermiddlewares.base import BaseSpiderMiddleware +from scrapy.utils.decorators import _warn_spider_arg if TYPE_CHECKING: from collections.abc import AsyncIterator, Iterable @@ -52,25 +53,30 @@ class DepthMiddleware(BaseSpiderMiddleware): o.crawler = crawler return o + @_warn_spider_arg def process_spider_output( - self, response: Response, result: Iterable[Any], spider: Spider + self, response: Response, result: Iterable[Any], spider: Spider | None = None ) -> Iterable[Any]: - self._init_depth(response, spider) - yield from super().process_spider_output(response, result, spider) + self._init_depth(response) + yield from super().process_spider_output(response, result) + @_warn_spider_arg async def process_spider_output_async( - self, response: Response, result: AsyncIterator[Any], spider: Spider + self, + response: Response, + result: AsyncIterator[Any], + spider: Spider | None = None, ) -> AsyncIterator[Any]: - self._init_depth(response, spider) - async for o in super().process_spider_output_async(response, result, spider): + self._init_depth(response) + async for o in super().process_spider_output_async(response, result): yield o - def _init_depth(self, response: Response, spider: Spider) -> None: + def _init_depth(self, response: Response) -> None: # base case (depth=0) if "depth" not in response.meta: response.meta["depth"] = 0 if self.verbose_stats: - self.stats.inc_value("request_depth_count/0", spider=spider) + self.stats.inc_value("request_depth_count/0") def get_processed_request( self, request: Request, response: Response | None @@ -90,8 +96,6 @@ class DepthMiddleware(BaseSpiderMiddleware): ) return None if self.verbose_stats: - self.stats.inc_value( - f"request_depth_count/{depth}", spider=self.crawler.spider - ) - self.stats.max_value("request_depth_max", depth, spider=self.crawler.spider) + self.stats.inc_value(f"request_depth_count/{depth}") + self.stats.max_value("request_depth_max", depth) return request diff --git a/scrapy/spidermiddlewares/httperror.py b/scrapy/spidermiddlewares/httperror.py index 42619ec7f..c160d1adb 100644 --- a/scrapy/spidermiddlewares/httperror.py +++ b/scrapy/spidermiddlewares/httperror.py @@ -10,6 +10,7 @@ import logging from typing import TYPE_CHECKING, Any from scrapy.exceptions import IgnoreRequest +from scrapy.utils.decorators import _warn_spider_arg if TYPE_CHECKING: from collections.abc import Iterable @@ -35,9 +36,7 @@ class HttpError(IgnoreRequest): class HttpErrorMiddleware: - @classmethod - def from_crawler(cls, crawler: Crawler) -> Self: - return cls(crawler.settings) + crawler: Crawler def __init__(self, settings: BaseSettings): self.handle_httpstatus_all: bool = settings.getbool("HTTPERROR_ALLOW_ALL") @@ -45,7 +44,16 @@ class HttpErrorMiddleware: "HTTPERROR_ALLOWED_CODES" ) - def process_spider_input(self, response: Response, spider: Spider) -> None: + @classmethod + def from_crawler(cls, crawler: Crawler) -> Self: + o = cls(crawler.settings) + o.crawler = crawler + return o + + @_warn_spider_arg + def process_spider_input( + self, response: Response, spider: Spider | None = None + ) -> None: if 200 <= response.status < 300: # common case return meta = response.meta @@ -57,25 +65,28 @@ class HttpErrorMiddleware: return else: allowed_statuses = getattr( - spider, "handle_httpstatus_list", self.handle_httpstatus_list + self.crawler.spider, + "handle_httpstatus_list", + self.handle_httpstatus_list, ) if response.status in allowed_statuses: return raise HttpError(response, "Ignoring non-200 response") + @_warn_spider_arg def process_spider_exception( - self, response: Response, exception: Exception, spider: Spider + self, response: Response, exception: Exception, spider: Spider | None = None ) -> Iterable[Any] | None: if isinstance(exception, HttpError): - assert spider.crawler.stats - spider.crawler.stats.inc_value("httperror/response_ignored_count") - spider.crawler.stats.inc_value( + assert self.crawler.stats + self.crawler.stats.inc_value("httperror/response_ignored_count") + self.crawler.stats.inc_value( f"httperror/response_ignored_status_count/{response.status}" ) logger.info( "Ignoring response %(response)r: HTTP status code is not handled or not allowed", {"response": response}, - extra={"spider": spider}, + extra={"spider": self.crawler.spider}, ) return [] return None diff --git a/scrapy/spidermiddlewares/offsite.py b/scrapy/spidermiddlewares/offsite.py deleted file mode 100644 index 2463275d5..000000000 --- a/scrapy/spidermiddlewares/offsite.py +++ /dev/null @@ -1,120 +0,0 @@ -""" -Offsite Spider Middleware - -See documentation in docs/topics/spider-middleware.rst -""" - -from __future__ import annotations - -import logging -import re -import warnings -from typing import TYPE_CHECKING - -from scrapy import Spider, signals -from scrapy.exceptions import ScrapyDeprecationWarning -from scrapy.spidermiddlewares.base import BaseSpiderMiddleware -from scrapy.utils.httpobj import urlparse_cached - -warnings.warn( - "The scrapy.spidermiddlewares.offsite module is deprecated, use " - "scrapy.downloadermiddlewares.offsite instead.", - ScrapyDeprecationWarning, -) - -if TYPE_CHECKING: - # typing.Self requires Python 3.11 - from typing_extensions import Self - - from scrapy.crawler import Crawler - from scrapy.http import Request, Response - from scrapy.statscollectors import StatsCollector - - -logger = logging.getLogger(__name__) - - -class OffsiteMiddleware(BaseSpiderMiddleware): - crawler: Crawler - - def __init__(self, stats: StatsCollector): # pylint: disable=super-init-not-called - self.stats: StatsCollector = stats - - @classmethod - def from_crawler(cls, crawler: Crawler) -> Self: - assert crawler.stats - o = cls(crawler.stats) - o.crawler = crawler - crawler.signals.connect(o.spider_opened, signal=signals.spider_opened) - return o - - def get_processed_request( - self, request: Request, response: Response | None - ) -> Request | None: - if response is None: - # skip start requests for backward compatibility - return request - assert self.crawler.spider - if ( - request.dont_filter - or request.meta.get("allow_offsite") - or self.should_follow(request, self.crawler.spider) - ): - return request - domain = urlparse_cached(request).hostname - if domain and domain not in self.domains_seen: - self.domains_seen.add(domain) - logger.debug( - "Filtered offsite request to %(domain)r: %(request)s", - {"domain": domain, "request": request}, - extra={"spider": self.crawler.spider}, - ) - self.stats.inc_value("offsite/domains", spider=self.crawler.spider) - self.stats.inc_value("offsite/filtered", spider=self.crawler.spider) - return None - - def should_follow(self, request: Request, spider: Spider) -> bool: - regex = self.host_regex - # hostname can be None for wrong urls (like javascript links) - host = urlparse_cached(request).hostname or "" - return bool(regex.search(host)) - - def get_host_regex(self, spider: Spider) -> re.Pattern[str]: - """Override this method to implement a different offsite policy""" - allowed_domains = getattr(spider, "allowed_domains", None) - if not allowed_domains: - return re.compile("") # allow all by default - url_pattern = re.compile(r"^https?://.*$") - port_pattern = re.compile(r":\d+$") - domains = [] - for domain in allowed_domains: - if domain is None: - continue - if url_pattern.match(domain): - message = ( - "allowed_domains accepts only domains, not URLs. " - f"Ignoring URL entry {domain} in allowed_domains." - ) - warnings.warn(message, URLWarning) - elif port_pattern.search(domain): - message = ( - "allowed_domains accepts only domains without ports. " - f"Ignoring entry {domain} in allowed_domains." - ) - warnings.warn(message, PortWarning) - else: - domains.append(re.escape(domain)) - regex = rf"^(.*\.)?({'|'.join(domains)})$" - return re.compile(regex) - - def spider_opened(self, spider: Spider) -> None: - self.host_regex: re.Pattern[str] = self.get_host_regex(spider) - self.domains_seen: set[str] = set() - - -class URLWarning(Warning): - pass - - -class PortWarning(Warning): - pass diff --git a/scrapy/spidermiddlewares/referer.py b/scrapy/spidermiddlewares/referer.py index f5d406c13..e4d0f4014 100644 --- a/scrapy/spidermiddlewares/referer.py +++ b/scrapy/spidermiddlewares/referer.py @@ -6,6 +6,7 @@ originated it. from __future__ import annotations import warnings +from abc import ABC, abstractmethod from typing import TYPE_CHECKING, cast from urllib.parse import urlparse @@ -45,10 +46,13 @@ POLICY_UNSAFE_URL = "unsafe-url" POLICY_SCRAPY_DEFAULT = "scrapy-default" -class ReferrerPolicy: +class ReferrerPolicy(ABC): + """Abstract base class for referrer policies.""" + NOREFERRER_SCHEMES: tuple[str, ...] = LOCAL_SCHEMES name: str + @abstractmethod def referrer(self, response_url: str, request_url: str) -> str | None: raise NotImplementedError @@ -311,7 +315,7 @@ def _load_policy_class( from https://www.w3.org/TR/referrer-policy/#referrer-policies """ try: - return cast(type[ReferrerPolicy], load_object(policy)) + return cast("type[ReferrerPolicy]", load_object(policy)) except ValueError: tokens = [token.strip() for token in policy.lower().split(",")] # https://www.w3.org/TR/referrer-policy/#parse-referrer-policy-from-header diff --git a/scrapy/spidermiddlewares/urllength.py b/scrapy/spidermiddlewares/urllength.py index 5590165a5..f325ce7a0 100644 --- a/scrapy/spidermiddlewares/urllength.py +++ b/scrapy/spidermiddlewares/urllength.py @@ -49,7 +49,5 @@ class UrlLengthMiddleware(BaseSpiderMiddleware): extra={"spider": self.crawler.spider}, ) assert self.crawler.stats - self.crawler.stats.inc_value( - "urllength/request_ignored_count", spider=self.crawler.spider - ) + self.crawler.stats.inc_value("urllength/request_ignored_count") return None diff --git a/scrapy/spiders/__init__.py b/scrapy/spiders/__init__.py index a722dd83b..018e510c5 100644 --- a/scrapy/spiders/__init__.py +++ b/scrapy/spiders/__init__.py @@ -26,7 +26,7 @@ if TYPE_CHECKING: from scrapy.crawler import Crawler from scrapy.http.request import CallbackT - from scrapy.settings import BaseSettings, _SettingsKeyT + from scrapy.settings import BaseSettings, _SettingsKey from scrapy.utils.log import SpiderLoggerAdapter @@ -39,7 +39,7 @@ class Spider(object_ref): """ name: str - custom_settings: dict[_SettingsKeyT, Any] | None = None + custom_settings: dict[_SettingsKey, Any] | None = None #: Start URLs. See :meth:`start`. start_urls: list[str] @@ -55,7 +55,8 @@ class Spider(object_ref): @property def logger(self) -> SpiderLoggerAdapter: - from scrapy.utils.log import SpiderLoggerAdapter + # circular import + from scrapy.utils.log import SpiderLoggerAdapter # noqa: PLC0415 logger = logging.getLogger(self.name) return SpiderLoggerAdapter(logger, {"spider": self}) diff --git a/scrapy/spiders/crawl.py b/scrapy/spiders/crawl.py index 98e7b23c0..89421cbe4 100644 --- a/scrapy/spiders/crawl.py +++ b/scrapy/spiders/crawl.py @@ -10,9 +10,7 @@ from __future__ import annotations import copy import warnings from collections.abc import AsyncIterator, Awaitable, Callable -from typing import TYPE_CHECKING, Any, Optional, TypeVar, cast - -from twisted.python.failure import Failure +from typing import TYPE_CHECKING, Any, TypeAlias, TypeVar, cast from scrapy.http import HtmlResponse, Request, Response from scrapy.link import Link @@ -26,6 +24,8 @@ from scrapy.utils.spider import iterate_spider_output if TYPE_CHECKING: from collections.abc import Iterable, Sequence + from twisted.python.failure import Failure + # typing.Self requires Python 3.11 from typing_extensions import Self @@ -34,8 +34,8 @@ if TYPE_CHECKING: _T = TypeVar("_T") -ProcessLinksT = Callable[[list[Link]], list[Link]] -ProcessRequestT = Callable[[Request, Response], Optional[Request]] +ProcessLinksT: TypeAlias = Callable[[list[Link]], list[Link]] +ProcessRequestT: TypeAlias = Callable[[Request, Response], Request | None] def _identity(x: _T) -> _T: @@ -81,12 +81,14 @@ class Rule: def _compile(self, spider: Spider) -> None: # this replaces method names with methods and we can't express this in type hints self.callback = cast("CallbackT", _get_method(self.callback, spider)) - self.errback = cast(Callable[[Failure], Any], _get_method(self.errback, spider)) + self.errback = cast( + "Callable[[Failure], Any]", _get_method(self.errback, spider) + ) self.process_links = cast( - ProcessLinksT, _get_method(self.process_links, spider) + "ProcessLinksT", _get_method(self.process_links, spider) ) self.process_request = cast( - ProcessRequestT, _get_method(self.process_request, spider) + "ProcessRequestT", _get_method(self.process_request, spider) ) @@ -141,13 +143,13 @@ class CrawlSpider(Spider): for lnk in rule.link_extractor.extract_links(response) if lnk not in seen ] - for link in cast(ProcessLinksT, rule.process_links)(links): + for link in cast("ProcessLinksT", rule.process_links)(links): seen.add(link) request = self._build_request(rule_index, link) - yield cast(ProcessRequestT, rule.process_request)(request, response) + yield cast("ProcessRequestT", rule.process_request)(request, response) def _callback(self, response: Response, **cb_kwargs: Any) -> Any: - rule = self._rules[cast(int, response.meta["rule"])] + rule = self._rules[cast("int", response.meta["rule"])] return self.parse_with_rules( response, cast("CallbackT", rule.callback), @@ -156,9 +158,9 @@ class CrawlSpider(Spider): ) def _errback(self, failure: Failure) -> Iterable[Any]: - rule = self._rules[cast(int, failure.request.meta["rule"])] # type: ignore[attr-defined] + rule = self._rules[cast("int", failure.request.meta["rule"])] # type: ignore[attr-defined] return self._handle_failure( - failure, cast(Callable[[Failure], Any], rule.errback) + failure, cast("Callable[[Failure], Any]", rule.errback) ) async def parse_with_rules( diff --git a/scrapy/spiders/init.py b/scrapy/spiders/init.py index e5548b9fa..957bfffd3 100644 --- a/scrapy/spiders/init.py +++ b/scrapy/spiders/init.py @@ -1,15 +1,16 @@ from __future__ import annotations import warnings -from collections.abc import AsyncIterator, Iterable from typing import TYPE_CHECKING, Any, cast -from scrapy import Request from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.spiders import Spider from scrapy.utils.spider import iterate_spider_output if TYPE_CHECKING: + from collections.abc import AsyncIterator, Iterable + + from scrapy import Request from scrapy.http import Response @@ -39,7 +40,7 @@ class InitSpider(Spider): def start_requests(self) -> Iterable[Request]: self._postinit_reqs: Iterable[Request] = super().start_requests() - return cast(Iterable[Request], iterate_spider_output(self.init_request())) + return cast("Iterable[Request]", iterate_spider_output(self.init_request())) def initialized(self, response: Response | None = None) -> Any: """This method must be set as the callback of your last initialization diff --git a/scrapy/statscollectors.py b/scrapy/statscollectors.py index f3dd0f8e7..f74842ac9 100644 --- a/scrapy/statscollectors.py +++ b/scrapy/statscollectors.py @@ -8,6 +8,8 @@ import logging import pprint from typing import TYPE_CHECKING, Any +from scrapy.utils.decorators import _warn_spider_arg + if TYPE_CHECKING: from scrapy import Spider from scrapy.crawler import Crawler @@ -23,6 +25,34 @@ class StatsCollector: def __init__(self, crawler: Crawler): self._dump: bool = crawler.settings.getbool("STATS_DUMP") self._stats: StatsT = {} + self._crawler: Crawler = crawler + + def __getattribute__(self, name): + cached_name = f"_cached_{name}" + try: + return super().__getattribute__(cached_name) + except AttributeError: + pass + + original_attr = super().__getattribute__(name) + + if name in { + "get_value", + "get_stats", + "set_value", + "set_stats", + "inc_value", + "max_value", + "min_value", + "clear_stats", + "open_spider", + "close_spider", + } and callable(original_attr): + wrapped = _warn_spider_arg(original_attr) + setattr(self, cached_name, wrapped) + return wrapped + + return original_attr def get_value( self, key: str, default: Any = None, spider: Spider | None = None @@ -53,18 +83,20 @@ class StatsCollector: def clear_stats(self, spider: Spider | None = None) -> None: self._stats.clear() - def open_spider(self, spider: Spider) -> None: + def open_spider(self, spider: Spider | None = None) -> None: pass - def close_spider(self, spider: Spider, reason: str) -> None: + def close_spider( + self, spider: Spider | None = None, reason: str | None = None + ) -> None: if self._dump: logger.info( "Dumping Scrapy stats:\n" + pprint.pformat(self._stats), - extra={"spider": spider}, + extra={"spider": self._crawler.spider}, ) - self._persist_stats(self._stats, spider) + self._persist_stats(self._stats) - def _persist_stats(self, stats: StatsT, spider: Spider) -> None: + def _persist_stats(self, stats: StatsT) -> None: pass @@ -73,8 +105,9 @@ class MemoryStatsCollector(StatsCollector): super().__init__(crawler) self.spider_stats: dict[str, StatsT] = {} - def _persist_stats(self, stats: StatsT, spider: Spider) -> None: - self.spider_stats[spider.name] = stats + def _persist_stats(self, stats: StatsT) -> None: + if self._crawler.spider: + self.spider_stats[self._crawler.spider.name] = stats class DummyStatsCollector(StatsCollector): diff --git a/scrapy/templates/project/module/middlewares.py.tmpl b/scrapy/templates/project/module/middlewares.py.tmpl index 3f0239832..a0b5910e0 100644 --- a/scrapy/templates/project/module/middlewares.py.tmpl +++ b/scrapy/templates/project/module/middlewares.py.tmpl @@ -45,7 +45,7 @@ class ${ProjectName}SpiderMiddleware: async def process_start(self, start): # Called with an async iterator over the spider start() method or the - # maching method of an earlier spider middleware. + # matching method of an earlier spider middleware. async for item_or_request in start: yield item_or_request diff --git a/scrapy/templates/project/module/settings.py.tmpl b/scrapy/templates/project/module/settings.py.tmpl index db7400af8..0432a7231 100644 --- a/scrapy/templates/project/module/settings.py.tmpl +++ b/scrapy/templates/project/module/settings.py.tmpl @@ -21,16 +21,10 @@ ADDONS = {} # Obey robots.txt rules ROBOTSTXT_OBEY = True -# Configure maximum concurrent requests performed by Scrapy (default: 16) -#CONCURRENT_REQUESTS = 32 - -# Configure a delay for requests for the same website (default: 0) -# See https://docs.scrapy.org/en/latest/topics/settings.html#download-delay -# See also autothrottle settings and docs -#DOWNLOAD_DELAY = 3 -# The download delay setting will honor only one of: -#CONCURRENT_REQUESTS_PER_DOMAIN = 16 -#CONCURRENT_REQUESTS_PER_IP = 16 +# Concurrency and throttling settings +#CONCURRENT_REQUESTS = 16 +CONCURRENT_REQUESTS_PER_DOMAIN = 1 +DOWNLOAD_DELAY = 1 # Disable cookies (enabled by default) #COOKIES_ENABLED = False diff --git a/scrapy/utils/_compression.py b/scrapy/utils/_compression.py index 6b09f36ff..4767c29f2 100644 --- a/scrapy/utils/_compression.py +++ b/scrapy/utils/_compression.py @@ -1,42 +1,12 @@ import contextlib import zlib from io import BytesIO -from warnings import warn -from scrapy.exceptions import ScrapyDeprecationWarning - -try: +with contextlib.suppress(ImportError): try: import brotli except ImportError: import brotlicffi as brotli -except ImportError: - pass -else: - try: - brotli.Decompressor.process - except AttributeError: - warn( - ( - "You have brotlipy installed, and Scrapy will use it, but " - "Scrapy support for brotlipy is deprecated and will stop " - "working in a future version of Scrapy. brotlipy itself is " - "deprecated, it has been superseded by brotlicffi. Please, " - "uninstall brotlipy and install brotli or brotlicffi instead. " - "brotlipy has the same import name as brotli, so keeping both " - "installed is strongly discouraged." - ), - ScrapyDeprecationWarning, - ) - - def _brotli_decompress(decompressor, data): - return decompressor.decompress(data) - - else: - - def _brotli_decompress(decompressor, data): - return decompressor.process(data) - with contextlib.suppress(ImportError): import zstandard @@ -46,62 +16,64 @@ _CHUNK_SIZE = 65536 # 64 KiB class _DecompressionMaxSizeExceeded(ValueError): - pass + def __init__(self, decompressed_size: int, max_size: int) -> None: + self.decompressed_size = decompressed_size + self.max_size = max_size + + def __str__(self) -> str: + return ( + f"The number of bytes decompressed so far " + f"({self.decompressed_size} B) exceeded the specified maximum " + f"({self.max_size} B)." + ) + + +def _check_max_size(decompressed_size: int, max_size: int) -> None: + if max_size and decompressed_size > max_size: + raise _DecompressionMaxSizeExceeded(decompressed_size, max_size) def _inflate(data: bytes, *, max_size: int = 0) -> bytes: decompressor = zlib.decompressobj() - raw_decompressor = zlib.decompressobj(wbits=-15) - input_stream = BytesIO(data) + try: + first_chunk = decompressor.decompress(data, max_length=_CHUNK_SIZE) + except zlib.error: + # to work with raw deflate content that may be sent by microsoft servers. + decompressor = zlib.decompressobj(wbits=-15) + first_chunk = decompressor.decompress(data, max_length=_CHUNK_SIZE) + decompressed_size = len(first_chunk) + _check_max_size(decompressed_size, max_size) output_stream = BytesIO() - output_chunk = b"." - decompressed_size = 0 - while output_chunk: - input_chunk = input_stream.read(_CHUNK_SIZE) - try: - output_chunk = decompressor.decompress(input_chunk) - except zlib.error: - if decompressor != raw_decompressor: - # ugly hack to work with raw deflate content that may - # be sent by microsoft servers. For more information, see: - # http://carsten.codimi.de/gzip.yaws/ - # http://www.port80software.com/200ok/archive/2005/10/31/868.aspx - # http://www.gzip.org/zlib/zlib_faq.html#faq38 - decompressor = raw_decompressor - output_chunk = decompressor.decompress(input_chunk) - else: - raise + output_stream.write(first_chunk) + while decompressor.unconsumed_tail: + output_chunk = decompressor.decompress( + decompressor.unconsumed_tail, max_length=_CHUNK_SIZE + ) decompressed_size += len(output_chunk) - if max_size and decompressed_size > max_size: - raise _DecompressionMaxSizeExceeded( - f"The number of bytes decompressed so far " - f"({decompressed_size} B) exceed the specified maximum " - f"({max_size} B)." - ) + _check_max_size(decompressed_size, max_size) output_stream.write(output_chunk) - output_stream.seek(0) - return output_stream.read() + if tail := decompressor.flush(): + decompressed_size += len(tail) + _check_max_size(decompressed_size, max_size) + output_stream.write(tail) + return output_stream.getvalue() def _unbrotli(data: bytes, *, max_size: int = 0) -> bytes: decompressor = brotli.Decompressor() - input_stream = BytesIO(data) + first_chunk = decompressor.process(data, output_buffer_limit=_CHUNK_SIZE) + decompressed_size = len(first_chunk) + _check_max_size(decompressed_size, max_size) output_stream = BytesIO() - output_chunk = b"." - decompressed_size = 0 - while output_chunk: - input_chunk = input_stream.read(_CHUNK_SIZE) - output_chunk = _brotli_decompress(decompressor, input_chunk) + output_stream.write(first_chunk) + while not decompressor.is_finished(): + output_chunk = decompressor.process(b"", output_buffer_limit=_CHUNK_SIZE) + if not output_chunk: + break decompressed_size += len(output_chunk) - if max_size and decompressed_size > max_size: - raise _DecompressionMaxSizeExceeded( - f"The number of bytes decompressed so far " - f"({decompressed_size} B) exceed the specified maximum " - f"({max_size} B)." - ) + _check_max_size(decompressed_size, max_size) output_stream.write(output_chunk) - output_stream.seek(0) - return output_stream.read() + return output_stream.getvalue() def _unzstd(data: bytes, *, max_size: int = 0) -> bytes: @@ -113,12 +85,6 @@ def _unzstd(data: bytes, *, max_size: int = 0) -> bytes: while output_chunk: output_chunk = stream_reader.read(_CHUNK_SIZE) decompressed_size += len(output_chunk) - if max_size and decompressed_size > max_size: - raise _DecompressionMaxSizeExceeded( - f"The number of bytes decompressed so far " - f"({decompressed_size} B) exceed the specified maximum " - f"({max_size} B)." - ) + _check_max_size(decompressed_size, max_size) output_stream.write(output_chunk) - output_stream.seek(0) - return output_stream.read() + return output_stream.getvalue() diff --git a/scrapy/utils/asyncio.py b/scrapy/utils/asyncio.py index 8c5b843cb..15e7736ec 100644 --- a/scrapy/utils/asyncio.py +++ b/scrapy/utils/asyncio.py @@ -6,7 +6,7 @@ import asyncio import logging import time from collections.abc import AsyncIterator, Callable, Coroutine, Iterable -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar from twisted.internet.defer import Deferred from twisted.internet.task import LoopingCall @@ -17,15 +17,14 @@ from scrapy.utils.reactor import is_asyncio_reactor_installed, is_reactor_instal if TYPE_CHECKING: from twisted.internet.base import DelayedCall - # typing.Concatenate and typing.ParamSpec require Python 3.10 # typing.Self, typing.TypeVarTuple and typing.Unpack require Python 3.11 - from typing_extensions import Concatenate, ParamSpec, Self, TypeVarTuple, Unpack + from typing_extensions import Self, TypeVarTuple, Unpack - _P = ParamSpec("_P") _Ts = TypeVarTuple("_Ts") _T = TypeVar("_T") +_P = ParamSpec("_P") logger = logging.getLogger(__name__) @@ -69,7 +68,7 @@ def is_asyncio_available() -> bool: async def _parallel_asyncio( iterable: Iterable[_T] | AsyncIterator[_T], count: int, - callable: Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]], + callable_: Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]], *args: _P.args, **kwargs: _P.kwargs, ) -> None: @@ -89,7 +88,7 @@ async def _parallel_asyncio( if item is None: break try: - await callable(item, *args, **kwargs) + await callable_(item, *args, **kwargs) finally: queue.task_done() diff --git a/scrapy/utils/benchserver.py b/scrapy/utils/benchserver.py index 923ec005e..e34b7190f 100644 --- a/scrapy/utils/benchserver.py +++ b/scrapy/utils/benchserver.py @@ -27,8 +27,8 @@ class Root(Resource): return b"" -def _getarg(request, name: bytes, default: Any = None, type=str): - return type(request.args[name][0]) if name in request.args else default +def _getarg(request, name: bytes, default: Any = None, type_=str): + return type_(request.args[name][0]) if name in request.args else default if __name__ == "__main__": diff --git a/scrapy/utils/boto.py b/scrapy/utils/boto.py index 53cfeddd0..73f86bc71 100644 --- a/scrapy/utils/boto.py +++ b/scrapy/utils/boto.py @@ -3,7 +3,7 @@ def is_botocore_available() -> bool: try: - import botocore # noqa: F401 + import botocore # noqa: F401,PLC0415 return True except ImportError: diff --git a/scrapy/utils/conf.py b/scrapy/utils/conf.py index 891cbb485..5869cf52e 100644 --- a/scrapy/utils/conf.py +++ b/scrapy/utils/conf.py @@ -6,7 +6,7 @@ import sys from configparser import ConfigParser from operator import itemgetter from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable, cast +from typing import TYPE_CHECKING, Any, cast from scrapy.exceptions import UsageError from scrapy.settings import BaseSettings @@ -14,7 +14,7 @@ from scrapy.utils.deprecate import update_classpath from scrapy.utils.python import without_none_values if TYPE_CHECKING: - from collections.abc import Collection, Iterable, Mapping, MutableMapping + from collections.abc import Callable, Collection, Iterable, Mapping, MutableMapping def build_component_list( @@ -153,7 +153,7 @@ def feed_process_params_from_cli( suitable to be used as the FEEDS setting. """ valid_output_formats: Iterable[str] = without_none_values( - cast(dict[str, str], settings.getwithbase("FEED_EXPORTERS")) + cast("dict[str, str]", settings.getwithbase("FEED_EXPORTERS")) ).keys() def check_valid_format(output_format: str) -> None: diff --git a/scrapy/utils/console.py b/scrapy/utils/console.py index 7425543ff..644965cb5 100644 --- a/scrapy/utils/console.py +++ b/scrapy/utils/console.py @@ -1,5 +1,6 @@ from __future__ import annotations +import code from collections.abc import Callable from functools import wraps from typing import TYPE_CHECKING, Any @@ -16,13 +17,13 @@ def _embed_ipython_shell( ) -> EmbedFuncT: """Start an IPython Shell""" try: - from IPython.terminal.embed import InteractiveShellEmbed # noqa: T100 - from IPython.terminal.ipapp import load_default_config + from IPython.terminal.embed import InteractiveShellEmbed # noqa: T100,PLC0415 + from IPython.terminal.ipapp import load_default_config # noqa: PLC0415 except ImportError: - from IPython.frontend.terminal.embed import ( # type: ignore[no-redef] # noqa: T100 + from IPython.frontend.terminal.embed import ( # type: ignore[no-redef] # noqa: T100,PLC0415 InteractiveShellEmbed, ) - from IPython.frontend.terminal.ipapp import ( # type: ignore[no-redef] + from IPython.frontend.terminal.ipapp import ( # type: ignore[no-redef] # noqa: PLC0415 load_default_config, ) @@ -46,7 +47,7 @@ def _embed_bpython_shell( namespace: dict[str, Any] = {}, banner: str = "" ) -> EmbedFuncT: """Start a bpython shell""" - import bpython + import bpython # noqa: PLC0415 @wraps(_embed_bpython_shell) def wrapper(namespace: dict[str, Any] = namespace, banner: str = "") -> None: @@ -59,7 +60,7 @@ def _embed_ptpython_shell( namespace: dict[str, Any] = {}, banner: str = "" ) -> EmbedFuncT: """Start a ptpython shell""" - import ptpython.repl # pylint: disable=import-error + import ptpython.repl # noqa: PLC0415 # pylint: disable=import-error @wraps(_embed_ptpython_shell) def wrapper(namespace: dict[str, Any] = namespace, banner: str = "") -> None: @@ -73,14 +74,12 @@ def _embed_standard_shell( namespace: dict[str, Any] = {}, banner: str = "" ) -> EmbedFuncT: """Start a standard python shell""" - import code - try: # readline module is only available on unix systems - import readline + import readline # noqa: PLC0415 except ImportError: pass else: - import rlcompleter # noqa: F401 + import rlcompleter # noqa: F401,PLC0415 readline.parse_and_bind("tab:complete") # type: ignore[attr-defined] diff --git a/scrapy/utils/datatypes.py b/scrapy/utils/datatypes.py index 3d0e0d3c7..ae7f627e1 100644 --- a/scrapy/utils/datatypes.py +++ b/scrapy/utils/datatypes.py @@ -32,7 +32,8 @@ class CaselessDict(dict): __slots__ = () def __new__(cls, *args: Any, **kwargs: Any) -> Self: - from scrapy.http.headers import Headers + # circular import + from scrapy.http.headers import Headers # noqa: PLC0415 if issubclass(cls, CaselessDict) and not issubclass(cls, Headers): warnings.warn( diff --git a/scrapy/utils/decorators.py b/scrapy/utils/decorators.py index 0f4d0beda..2dde0aa55 100644 --- a/scrapy/utils/decorators.py +++ b/scrapy/utils/decorators.py @@ -1,8 +1,9 @@ from __future__ import annotations +import inspect import warnings from functools import wraps -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar, overload from twisted.internet.defer import Deferred, maybeDeferred from twisted.internet.threads import deferToThread @@ -10,15 +11,11 @@ from twisted.internet.threads import deferToThread from scrapy.exceptions import ScrapyDeprecationWarning if TYPE_CHECKING: - from collections.abc import Callable - - # typing.ParamSpec requires Python 3.10 - from typing_extensions import ParamSpec - - _P = ParamSpec("_P") + from collections.abc import AsyncGenerator, Callable, Coroutine _T = TypeVar("_T") +_P = ParamSpec("_P") def deprecated( @@ -30,7 +27,7 @@ def deprecated( def deco(func: Callable[_P, _T]) -> Callable[_P, _T]: @wraps(func) - def wrapped(*args: _P.args, **kwargs: _P.kwargs) -> Any: + def wrapped(*args: _P.args, **kwargs: _P.kwargs) -> _T: message = f"Call to deprecated function {func.__name__}." if use_instead: message += f" Use {use_instead} instead." @@ -65,3 +62,69 @@ def inthread(func: Callable[_P, _T]) -> Callable[_P, Deferred[_T]]: return deferToThread(func, *a, **kw) return wrapped + + +@overload +def _warn_spider_arg( + func: Callable[_P, Coroutine[Any, Any, _T]], +) -> Callable[_P, Coroutine[Any, Any, _T]]: ... + + +@overload +def _warn_spider_arg( + func: Callable[_P, AsyncGenerator[_T]], +) -> Callable[_P, AsyncGenerator[_T]]: ... + + +@overload +def _warn_spider_arg(func: Callable[_P, _T]) -> Callable[_P, _T]: ... + + +def _warn_spider_arg( + func: Callable[_P, _T], +) -> ( + Callable[_P, _T] + | Callable[_P, Coroutine[Any, Any, _T]] + | Callable[_P, AsyncGenerator[_T]] +): + """Decorator to warn if a ``spider`` argument is passed to a function.""" + + sig = inspect.signature(func) + + def check_args(*args: _P.args, **kwargs: _P.kwargs) -> None: + bound = sig.bind(*args, **kwargs) + if "spider" in bound.arguments: + warnings.warn( + f"Passing a 'spider' argument to {func.__qualname__}() is deprecated and " + "the argument will be removed in a future Scrapy version.", + category=ScrapyDeprecationWarning, + stacklevel=3, + ) + + if inspect.iscoroutinefunction(func): + + @wraps(func) + async def async_inner(*args: _P.args, **kwargs: _P.kwargs) -> _T: + check_args(*args, **kwargs) + return await func(*args, **kwargs) + + return async_inner + + if inspect.isasyncgenfunction(func): + + @wraps(func) + async def asyncgen_inner( + *args: _P.args, **kwargs: _P.kwargs + ) -> AsyncGenerator[_T]: + check_args(*args, **kwargs) + async for item in func(*args, **kwargs): + yield item + + return asyncgen_inner + + @wraps(func) + def sync_inner(*args: _P.args, **kwargs: _P.kwargs) -> _T: + check_args(*args, **kwargs) + return func(*args, **kwargs) + + return sync_inner diff --git a/scrapy/utils/defer.py b/scrapy/utils/defer.py index 2df82c8f2..8ca3bc0bd 100644 --- a/scrapy/utils/defer.py +++ b/scrapy/utils/defer.py @@ -10,33 +10,33 @@ import warnings from asyncio import Future from collections.abc import Awaitable, Coroutine, Iterable, Iterator from functools import wraps -from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast, overload - -from twisted.internet.defer import ( - Deferred, - DeferredList, - fail, - succeed, +from typing import ( + TYPE_CHECKING, + Any, + Concatenate, + Generic, + ParamSpec, + TypeVar, + cast, + overload, ) + +from twisted.internet.defer import Deferred, DeferredList, fail, succeed from twisted.internet.task import Cooperator from twisted.python import failure -from scrapy.exceptions import IgnoreRequest, ScrapyDeprecationWarning -from scrapy.utils.asyncio import is_asyncio_available +from scrapy.exceptions import ScrapyDeprecationWarning +from scrapy.utils.asyncio import call_later, is_asyncio_available if TYPE_CHECKING: from collections.abc import AsyncIterator, Callable from twisted.python.failure import Failure - # typing.Concatenate and typing.ParamSpec require Python 3.10 - from typing_extensions import Concatenate, ParamSpec - - _P = ParamSpec("_P") - _T = TypeVar("_T") _T2 = TypeVar("_T2") +_P = ParamSpec("_P") _DEFER_DELAY = 0.1 @@ -49,6 +49,13 @@ def defer_fail(_failure: Failure) -> Deferred[Any]: It delays by 100ms so reactor has a chance to go through readers and writers before attending pending delayed calls, so do not set delay to zero. """ + warnings.warn( + "scrapy.utils.defer.defer_fail() is deprecated, use" + " twisted.internet.defer.fail(), plus an explicit sleep if needed.", + category=ScrapyDeprecationWarning, + stacklevel=2, + ) + from twisted.internet import reactor d: Deferred[Any] = Deferred() @@ -63,6 +70,13 @@ def defer_succeed(result: _T) -> Deferred[_T]: It delays by 100ms so reactor has a chance to go through readers and writers before attending pending delayed calls, so do not set delay to zero. """ + warnings.warn( + "scrapy.utils.defer.defer_succeed() is deprecated, use" + " twisted.internet.defer.succeed(), plus an explicit sleep if needed.", + category=ScrapyDeprecationWarning, + stacklevel=2, + ) + from twisted.internet import reactor d: Deferred[_T] = Deferred() @@ -71,20 +85,44 @@ def defer_succeed(result: _T) -> Deferred[_T]: def _defer_sleep() -> Deferred[None]: - """Like ``defer_succeed`` and ``defer_fail`` but doesn't call any real callbacks.""" - from twisted.internet import reactor - + """Delay by _DEFER_DELAY so reactor has a chance to go through readers and writers + before attending pending delayed calls, so do not set delay to zero. + """ d: Deferred[None] = Deferred() - reactor.callLater(_DEFER_DELAY, d.callback, None) + call_later(_DEFER_DELAY, d.callback, None) return d +async def _defer_sleep_async() -> None: + """Delay by _DEFER_DELAY so reactor has a chance to go through readers and writers + before attending pending delayed calls, so do not set delay to zero. + """ + if is_asyncio_available(): + await asyncio.sleep(_DEFER_DELAY) + else: + await _defer_sleep() + + def defer_result(result: Any) -> Deferred[Any]: + warnings.warn( + "scrapy.utils.defer.defer_result() is deprecated, use" + " twisted.internet.defer.success() and twisted.internet.defer.fail()," + " plus an explicit sleep if needed, or explicit reactor.callLater().", + category=ScrapyDeprecationWarning, + stacklevel=2, + ) + if isinstance(result, Deferred): return result + + from twisted.internet import reactor + + d: Deferred[Any] = Deferred() if isinstance(result, failure.Failure): - return defer_fail(result) - return defer_succeed(result) + reactor.callLater(_DEFER_DELAY, d.errback, result) + else: + reactor.callLater(_DEFER_DELAY, d.callback, result) + return d @overload @@ -93,14 +131,6 @@ def mustbe_deferred( ) -> Deferred[_T]: ... -@overload -def mustbe_deferred( - f: Callable[_P, Coroutine[Deferred[Any], Any, _T]], - *args: _P.args, - **kw: _P.kwargs, -) -> Deferred[_T]: ... - - @overload def mustbe_deferred( f: Callable[_P, _T], *args: _P.args, **kw: _P.kwargs @@ -108,29 +138,31 @@ def mustbe_deferred( def mustbe_deferred( - f: Callable[_P, Deferred[_T] | Coroutine[Deferred[Any], Any, _T] | _T], + f: Callable[_P, Deferred[_T] | _T], *args: _P.args, **kw: _P.kwargs, ) -> Deferred[_T]: """Same as twisted.internet.defer.maybeDeferred, but delay calling callback/errback to next reactor loop """ + warnings.warn( + "scrapy.utils.defer.mustbe_deferred() is deprecated, use" + " twisted.internet.defer.maybeDeferred(), with an explicit sleep if needed.", + category=ScrapyDeprecationWarning, + stacklevel=2, + ) + result: _T | Deferred[_T] | Failure try: result = f(*args, **kw) - # FIXME: Hack to avoid introspecting tracebacks. This to speed up - # processing of IgnoreRequest errors which are, by far, the most common - # exception in Scrapy - see #125 - except IgnoreRequest as e: - return defer_fail(failure.Failure(e)) except Exception: - return defer_fail(failure.Failure()) + result = failure.Failure() return defer_result(result) def parallel( iterable: Iterable[_T], count: int, - callable: Callable[Concatenate[_T, _P], _T2], + callable: Callable[Concatenate[_T, _P], _T2], # noqa: A002 *args: _P.args, **named: _P.kwargs, ) -> Deferred[list[tuple[bool, Iterator[_T2]]]]: @@ -193,12 +225,12 @@ class _AsyncCooperatorAdapter(Iterator, Generic[_T]): def __init__( self, aiterable: AsyncIterator[_T], - callable: Callable[Concatenate[_T, _P], Deferred[Any] | None], + callable_: Callable[Concatenate[_T, _P], Deferred[Any] | None], *callable_args: _P.args, **callable_kwargs: _P.kwargs, ): self.aiterator: AsyncIterator[_T] = aiterable.__aiter__() - self.callable: Callable[Concatenate[_T, _P], Deferred[Any] | None] = callable + self.callable: Callable[Concatenate[_T, _P], Deferred[Any] | None] = callable_ self.callable_args: tuple[Any, ...] = callable_args self.callable_kwargs: dict[str, Any] = callable_kwargs self.finished: bool = False @@ -251,7 +283,7 @@ class _AsyncCooperatorAdapter(Iterator, Generic[_T]): def parallel_async( async_iterable: AsyncIterator[_T], count: int, - callable: Callable[Concatenate[_T, _P], Deferred[Any] | None], + callable: Callable[Concatenate[_T, _P], Deferred[Any] | None], # noqa: A002 *args: _P.args, **named: _P.kwargs, ) -> Deferred[list[tuple[bool, Iterator[Deferred[Any]]]]]: @@ -268,11 +300,16 @@ def parallel_async( def process_chain( callbacks: Iterable[Callable[Concatenate[_T, _P], _T]], - input: _T, + input: _T, # noqa: A002 *a: _P.args, **kw: _P.kwargs, ) -> Deferred[_T]: """Return a Deferred built by chaining the given callbacks""" + warnings.warn( + "process_chain() is deprecated.", + category=ScrapyDeprecationWarning, + stacklevel=2, + ) d: Deferred[_T] = Deferred() for x in callbacks: d.addCallback(x, *a, **kw) @@ -280,40 +317,20 @@ def process_chain( return d -def process_chain_both( - callbacks: Iterable[Callable[Concatenate[_T, _P], Any]], - errbacks: Iterable[Callable[Concatenate[Failure, _P], Any]], - input: Any, - *a: _P.args, - **kw: _P.kwargs, -) -> Deferred: - """Return a Deferred built by chaining the given callbacks and errbacks""" - warnings.warn( - "process_chain_both() is deprecated and will be removed in a future" - " Scrapy version.", - ScrapyDeprecationWarning, - stacklevel=2, - ) - d: Deferred = Deferred() - for cb, eb in zip(callbacks, errbacks): - d.addCallback(cb, *a, **kw) - d.addErrback(eb, *a, **kw) - if isinstance(input, failure.Failure): - d.errback(input) - else: - d.callback(input) - return d - - def process_parallel( callbacks: Iterable[Callable[Concatenate[_T, _P], _T2]], - input: _T, + input: _T, # noqa: A002 *a: _P.args, **kw: _P.kwargs, -) -> Deferred[list[_T2]]: +) -> Deferred[list[_T2]]: # pragma: no cover """Return a Deferred with the output of all successful calls to the given callbacks """ + warnings.warn( + "process_parallel() is deprecated.", + category=ScrapyDeprecationWarning, + stacklevel=2, + ) dfds = [succeed(input).addCallback(x, *a, **kw) for x in callbacks] d: Deferred[list[tuple[bool, _T2]]] = DeferredList( dfds, fireOnOneErrback=True, consumeErrors=True @@ -333,7 +350,7 @@ def iter_errback( *a: _P.args, **kw: _P.kwargs, ) -> Iterable[_T]: - """Wraps an iterable calling an errback if an error is caught while + """Wrap an iterable calling an errback if an error is caught while iterating it. """ it = iter(iterable) @@ -352,7 +369,7 @@ async def aiter_errback( *a: _P.args, **kw: _P.kwargs, ) -> AsyncIterator[_T]: - """Wraps an async iterable calling an errback if an error is caught while + """Wrap an async iterable calling an errback if an error is caught while iterating it. Similar to :func:`scrapy.utils.defer.iter_errback`. """ it = aiterable.__aiter__() @@ -374,15 +391,15 @@ def deferred_from_coro(o: _T2) -> _T2: ... def deferred_from_coro(o: Awaitable[_T] | _T2) -> Deferred[_T] | _T2: - """Converts a coroutine or other awaitable object into a Deferred, - or returns the object as is if it isn't a coroutine.""" + """Convert a coroutine or other awaitable object into a Deferred, + or return the object as is if it isn't a coroutine.""" if isinstance(o, Deferred): return o if inspect.isawaitable(o): if not is_asyncio_available(): # wrapping the coroutine directly into a Deferred, this doesn't work correctly with coroutines # that use asyncio, e.g. "await asyncio.sleep(1)" - return Deferred.fromCoroutine(cast(Coroutine[Deferred[Any], Any, _T], o)) + return Deferred.fromCoroutine(cast("Coroutine[Deferred[Any], Any, _T]", o)) # wrapping the coroutine into a Future and then into a Deferred, this requires AsyncioSelectorReactor return Deferred.fromFuture(asyncio.ensure_future(o)) return o @@ -391,7 +408,7 @@ def deferred_from_coro(o: Awaitable[_T] | _T2) -> Deferred[_T] | _T2: def deferred_f_from_coro_f( coro_f: Callable[_P, Awaitable[_T]], ) -> Callable[_P, Deferred[_T]]: - """Converts a coroutine function into a function that returns a Deferred. + """Convert a coroutine function into a function that returns a Deferred. The coroutine function will be called at the time when the wrapper is called. Wrapper args will be passed to it. This is useful for callback chains, as callback functions are called with the previous callback result. @@ -423,10 +440,7 @@ def maybeDeferred_coro( def deferred_to_future(d: Deferred[_T]) -> Future[_T]: - """ - .. versionadded:: 2.6.0 - - Return an :class:`asyncio.Future` object that wraps *d*. + """Return an :class:`asyncio.Future` object that wraps *d*. This function requires :class:`~twisted.internet.asyncioreactor.AsyncioSelectorReactor` to be @@ -445,6 +459,8 @@ def deferred_to_future(d: Deferred[_T]) -> Future[_T]: deferred = self.crawler.engine.download(additional_request) additional_response = await deferred_to_future(deferred) + .. versionadded:: 2.6.0 + .. versionchanged:: VERSION This function no longer installs an asyncio loop if called before the Twisted asyncio reactor is installed. A :exc:`RuntimeError` is raised @@ -456,10 +472,7 @@ def deferred_to_future(d: Deferred[_T]) -> Future[_T]: def maybe_deferred_to_future(d: Deferred[_T]) -> Deferred[_T] | Future[_T]: - """ - .. versionadded:: 2.6.0 - - Return *d* as an object that can be awaited from a :ref:`Scrapy callable + """Return *d* as an object that can be awaited from a :ref:`Scrapy callable defined as a coroutine `. What you can await in Scrapy callables defined as coroutines depends on the @@ -480,7 +493,53 @@ def maybe_deferred_to_future(d: Deferred[_T]) -> Deferred[_T] | Future[_T]: additional_request = scrapy.Request('https://example.org/price') deferred = self.crawler.engine.download(additional_request) additional_response = await maybe_deferred_to_future(deferred) + + .. versionadded:: 2.6.0 """ if not is_asyncio_available(): return d return deferred_to_future(d) + + +def _schedule_coro(coro: Coroutine[Any, Any, Any]) -> None: + """Schedule the coroutine as a task or a Deferred. + + This doesn't store the reference to the task/Deferred, so a better + alternative is calling :func:`scrapy.utils.defer.deferred_from_coro`, + keeping the result, and adding proper exception handling (e.g. errbacks) to + it. + """ + if not is_asyncio_available(): + Deferred.fromCoroutine(coro) + return + loop = asyncio.get_event_loop() + loop.create_task(coro) # noqa: RUF006 + + +@overload +def ensure_awaitable(o: Awaitable[_T]) -> Awaitable[_T]: ... + + +@overload +def ensure_awaitable(o: _T) -> Awaitable[_T]: ... + + +def ensure_awaitable(o: _T | Awaitable[_T]) -> Awaitable[_T]: + """Convert any value to an awaitable object. + + For a :class:`~twisted.internet.defer.Deferred` object, use + :func:`maybe_deferred_to_future` to wrap it into a suitable object. For an + awaitable object of a different type, return it as is. For any other + value, return a coroutine that completes with that value. + + .. versionadded:: VERSION + """ + if isinstance(o, Deferred): + return maybe_deferred_to_future(o) + if inspect.isawaitable(o): + return o + + async def coro() -> _T: + return o + + return coro() diff --git a/scrapy/utils/deprecate.py b/scrapy/utils/deprecate.py index 20d03cae6..1f529b2cb 100644 --- a/scrapy/utils/deprecate.py +++ b/scrapy/utils/deprecate.py @@ -4,9 +4,13 @@ from __future__ import annotations import inspect import warnings -from typing import Any, overload +from typing import TYPE_CHECKING, Any, overload from scrapy.exceptions import ScrapyDeprecationWarning +from scrapy.utils.python import get_func_args_dict + +if TYPE_CHECKING: + from collections.abc import Callable def attribute(obj: Any, oldattr: str, newattr: str, version: str = "0.12") -> None: @@ -179,6 +183,8 @@ def method_is_overridden(subclass: type, base_class: type, method_name: str) -> ... pass >>> class Sub4(Sub2): ... pass + >>> method_is_overridden(Base, Base, 'foo') + False >>> method_is_overridden(Sub1, Base, 'foo') False >>> method_is_overridden(Sub2, Base, 'foo') @@ -191,3 +197,25 @@ def method_is_overridden(subclass: type, base_class: type, method_name: str) -> base_method = getattr(base_class, method_name) sub_method = getattr(subclass, method_name) return base_method.__code__ is not sub_method.__code__ + + +def argument_is_required(func: Callable[..., Any], arg_name: str) -> bool: + """ + Check if a function argument is required (exists and doesn't have a default value). + + .. versionadded:: VERSION + + >>> def func(a, b=1, c=None): + ... pass + >>> argument_is_required(func, 'a') + True + >>> argument_is_required(func, 'b') + False + >>> argument_is_required(func, 'c') + False + >>> argument_is_required(func, 'd') + False + """ + args = get_func_args_dict(func) + param = args.get(arg_name) + return param is not None and param.default is inspect.Parameter.empty diff --git a/scrapy/utils/display.py b/scrapy/utils/display.py index 20744a604..08e89dc0d 100644 --- a/scrapy/utils/display.py +++ b/scrapy/utils/display.py @@ -34,11 +34,11 @@ def _colorize(text: str, colorize: bool = True) -> str: if not colorize or not sys.stdout.isatty() or not _tty_supports_color(): return text try: - from pygments import highlight + from pygments import highlight # noqa: PLC0415 except ImportError: return text - from pygments.formatters import TerminalFormatter - from pygments.lexers import PythonLexer + from pygments.formatters import TerminalFormatter # noqa: PLC0415 + from pygments.lexers import PythonLexer # noqa: PLC0415 return highlight(text, PythonLexer(), TerminalFormatter()) diff --git a/scrapy/utils/gz.py b/scrapy/utils/gz.py index 85324361c..5f95bccbd 100644 --- a/scrapy/utils/gz.py +++ b/scrapy/utils/gz.py @@ -5,7 +5,7 @@ from gzip import GzipFile from io import BytesIO from typing import TYPE_CHECKING -from ._compression import _CHUNK_SIZE, _DecompressionMaxSizeExceeded +from ._compression import _CHUNK_SIZE, _check_max_size if TYPE_CHECKING: from scrapy.http import Response @@ -31,15 +31,9 @@ def gunzip(data: bytes, *, max_size: int = 0) -> bytes: break raise decompressed_size += len(chunk) - if max_size and decompressed_size > max_size: - raise _DecompressionMaxSizeExceeded( - f"The number of bytes decompressed so far " - f"({decompressed_size} B) exceed the specified maximum " - f"({max_size} B)." - ) + _check_max_size(decompressed_size, max_size) output_stream.write(chunk) - output_stream.seek(0) - return output_stream.read() + return output_stream.getvalue() def gzip_magic_number(response: Response) -> bool: diff --git a/scrapy/utils/iterators.py b/scrapy/utils/iterators.py index c646fc218..ad9640e47 100644 --- a/scrapy/utils/iterators.py +++ b/scrapy/utils/iterators.py @@ -147,12 +147,12 @@ class _StreamReader: def _read_string(self, n: int = 65535) -> bytes: s, e = self._ptr, self._ptr + n self._ptr = e - return cast(bytes, self._text)[s:e] + return cast("bytes", self._text)[s:e] def _read_unicode(self, n: int = 65535) -> bytes: s, e = self._ptr, self._ptr + n self._ptr = e - return cast(str, self._text)[s:e].encode("utf-8") + return cast("str", self._text)[s:e].encode("utf-8") def csviter( @@ -212,7 +212,7 @@ def csviter( }, ) continue - yield dict(zip(headers, row)) + yield dict(zip(headers, row, strict=False)) @overload diff --git a/scrapy/utils/log.py b/scrapy/utils/log.py index 24e17ecb6..6d3bb1db9 100644 --- a/scrapy/utils/log.py +++ b/scrapy/utils/log.py @@ -5,17 +5,19 @@ import pprint import sys from collections.abc import MutableMapping from logging.config import dictConfig -from types import TracebackType -from typing import TYPE_CHECKING, Any, Optional, cast +from typing import TYPE_CHECKING, Any, cast +from twisted.internet import asyncioreactor from twisted.python import log as twisted_log from twisted.python.failure import Failure import scrapy -from scrapy.settings import Settings, _SettingsKeyT +from scrapy.settings import Settings, _SettingsKey from scrapy.utils.versions import get_versions if TYPE_CHECKING: + from types import TracebackType + from scrapy.crawler import Crawler from scrapy.logformatter import LogFormatterResult @@ -33,7 +35,7 @@ def failure_to_exc_info( return ( failure.type, failure.value, - cast(Optional[TracebackType], failure.getTracebackObject()), + cast("TracebackType | None", failure.getTracebackObject()), ) return None @@ -81,7 +83,7 @@ DEFAULT_LOGGING = { def configure_logging( - settings: Settings | dict[_SettingsKeyT, Any] | None = None, + settings: Settings | dict[_SettingsKey, Any] | None = None, install_root_handler: bool = True, ) -> None: """ @@ -132,14 +134,21 @@ _scrapy_root_handler: logging.Handler | None = None def install_scrapy_root_handler(settings: Settings) -> None: global _scrapy_root_handler # noqa: PLW0603 # pylint: disable=global-statement + _uninstall_scrapy_root_handler() + logging.root.setLevel(logging.NOTSET) + _scrapy_root_handler = _get_handler(settings) + logging.root.addHandler(_scrapy_root_handler) + + +def _uninstall_scrapy_root_handler() -> None: + global _scrapy_root_handler # noqa: PLW0603 # pylint: disable=global-statement + if ( _scrapy_root_handler is not None and _scrapy_root_handler in logging.root.handlers ): logging.root.removeHandler(_scrapy_root_handler) - logging.root.setLevel(logging.NOTSET) - _scrapy_root_handler = _get_handler(settings) - logging.root.addHandler(_scrapy_root_handler) + _scrapy_root_handler = None def get_scrapy_root_handler() -> logging.Handler | None: @@ -182,7 +191,7 @@ def log_scrapy_info(settings: Settings) -> None: def log_reactor_info() -> None: - from twisted.internet import asyncioreactor, reactor + from twisted.internet import reactor logger.debug("Using reactor: %s.%s", reactor.__module__, reactor.__class__.__name__) if isinstance(reactor, asyncioreactor.AsyncioSelectorReactor): @@ -240,7 +249,7 @@ def logformatter_adapter( message = logkws.get("msg") or "" # NOTE: This also handles 'args' being an empty dict, that case doesn't # play well in logger.log calls - args = cast(dict[str, Any], logkws) if not logkws.get("args") else logkws["args"] + args = cast("dict[str, Any]", logkws) if not logkws.get("args") else logkws["args"] return (level, message, args) diff --git a/scrapy/utils/misc.py b/scrapy/utils/misc.py index b7b436260..7994a2471 100644 --- a/scrapy/utils/misc.py +++ b/scrapy/utils/misc.py @@ -9,7 +9,6 @@ import os import re import warnings from collections import deque -from collections.abc import Iterable from contextlib import contextmanager from functools import partial from importlib import import_module @@ -21,7 +20,7 @@ from scrapy.item import Item from scrapy.utils.datatypes import LocalWeakReferencedCache if TYPE_CHECKING: - from collections.abc import Callable, Iterator + from collections.abc import Callable, Iterable, Iterator from types import ModuleType from scrapy import Spider @@ -41,7 +40,7 @@ def arg_to_iter(arg: Any) -> Iterable[Any]: if arg is None: return [] if not isinstance(arg, _ITERABLE_SINGLE_VALUES) and hasattr(arg, "__iter__"): - return cast(Iterable[Any], arg) + return cast("Iterable[Any]", arg) return [arg] @@ -130,52 +129,10 @@ def rel_has_nofollow(rel: str | None) -> bool: return rel is not None and "nofollow" in rel.replace(",", " ").split() -def create_instance(objcls, settings, crawler, *args, **kwargs): - """Construct a class instance using its ``from_crawler`` or - ``from_settings`` constructors, if available. - - At least one of ``settings`` and ``crawler`` needs to be different from - ``None``. If ``settings `` is ``None``, ``crawler.settings`` will be used. - If ``crawler`` is ``None``, only the ``from_settings`` constructor will be - tried. - - ``*args`` and ``**kwargs`` are forwarded to the constructors. - - Raises ``ValueError`` if both ``settings`` and ``crawler`` are ``None``. - - .. versionchanged:: 2.2 - Raises ``TypeError`` if the resulting instance is ``None`` (e.g. if an - extension has not been implemented correctly). - """ - warnings.warn( - "The create_instance() function is deprecated. " - "Please use build_from_crawler() instead.", - category=ScrapyDeprecationWarning, - stacklevel=2, - ) - - if settings is None: - if crawler is None: - raise ValueError("Specify at least one of settings and crawler.") - settings = crawler.settings - if crawler and hasattr(objcls, "from_crawler"): - instance = objcls.from_crawler(crawler, *args, **kwargs) - method_name = "from_crawler" - elif hasattr(objcls, "from_settings"): - instance = objcls.from_settings(settings, *args, **kwargs) - method_name = "from_settings" - else: - instance = objcls(*args, **kwargs) - method_name = "__new__" - if instance is None: - raise TypeError(f"{objcls.__qualname__}.{method_name} returned None") - return instance - - def build_from_crawler( objcls: type[T], crawler: Crawler, /, *args: Any, **kwargs: Any ) -> T: - """Construct a class instance using its ``from_crawler`` or ``from_settings`` constructor. + """Construct a class instance using its ``from_crawler()`` or ``__init__()`` constructor. .. versionadded:: 2.12 @@ -186,23 +143,12 @@ def build_from_crawler( if hasattr(objcls, "from_crawler"): instance = objcls.from_crawler(crawler, *args, **kwargs) # type: ignore[attr-defined] method_name = "from_crawler" - elif hasattr(objcls, "from_settings"): - warnings.warn( - f"{objcls.__qualname__} has from_settings() but not from_crawler()." - " This is deprecated and calling from_settings() will be removed in a future" - " Scrapy version. You can implement a simple from_crawler() that calls" - " from_settings() with crawler.settings.", - category=ScrapyDeprecationWarning, - stacklevel=2, - ) - instance = objcls.from_settings(crawler.settings, *args, **kwargs) # type: ignore[attr-defined] - method_name = "from_settings" else: instance = objcls(*args, **kwargs) method_name = "__new__" if instance is None: raise TypeError(f"{objcls.__qualname__}.{method_name} returned None") - return cast(T, instance) + return cast("T", instance) @contextmanager @@ -242,7 +188,7 @@ def walk_callable(node: ast.AST) -> Iterable[ast.AST]: _generator_callbacks_cache = LocalWeakReferencedCache(limit=128) -def is_generator_with_return_value(callable: Callable[..., Any]) -> bool: +def is_generator_with_return_value(callable: Callable[..., Any]) -> bool: # noqa: A002 """ Returns True if a callable is a generator function which includes a 'return' statement with a value different than None, False otherwise @@ -280,7 +226,8 @@ def is_generator_with_return_value(callable: Callable[..., Any]) -> bool: def warn_on_generator_with_return_value( - spider: Spider, callable: Callable[..., Any] + spider: Spider, + callable: Callable[..., Any], # noqa: A002 ) -> None: """ Logs a warning if a callable is a generator function and includes diff --git a/scrapy/utils/ossignal.py b/scrapy/utils/ossignal.py index ad758b783..4eda29e25 100644 --- a/scrapy/utils/ossignal.py +++ b/scrapy/utils/ossignal.py @@ -3,12 +3,12 @@ from __future__ import annotations import signal from collections.abc import Callable from types import FrameType -from typing import Any, Optional, Union +from typing import Any, TypeAlias # copy of _HANDLER from typeshed/stdlib/signal.pyi -SignalHandlerT = Union[ - Callable[[int, Optional[FrameType]], Any], int, signal.Handlers, None -] +SignalHandlerT: TypeAlias = ( + Callable[[int, FrameType | None], Any] | int | signal.Handlers | None +) signal_names: dict[int, str] = {} for signame in dir(signal): diff --git a/scrapy/utils/python.py b/scrapy/utils/python.py index c859fbc2a..ca4b4bf28 100644 --- a/scrapy/utils/python.py +++ b/scrapy/utils/python.py @@ -8,70 +8,26 @@ import gc import inspect import re import sys -import warnings import weakref from collections.abc import AsyncIterator, Iterable, Mapping from functools import partial, wraps from itertools import chain -from typing import TYPE_CHECKING, Any, TypeVar, overload +from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar, overload -from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.utils.asyncgen import as_async_generator if TYPE_CHECKING: from collections.abc import Callable, Iterator from re import Pattern - # typing.Concatenate and typing.ParamSpec require Python 3.10 # typing.Self requires Python 3.11 - from typing_extensions import Concatenate, ParamSpec, Self + from typing_extensions import Self - _P = ParamSpec("_P") _T = TypeVar("_T") _KT = TypeVar("_KT") _VT = TypeVar("_VT") - - -def flatten(x: Iterable[Any]) -> list[Any]: - """flatten(sequence) -> list - - Returns a single, flat list which contains all elements retrieved - from the sequence and all recursively contained sub-sequences - (iterables). - - Examples: - >>> [1, 2, [3,4], (5,6)] - [1, 2, [3, 4], (5, 6)] - >>> flatten([[[1,2,3], (42,None)], [4,5], [6], 7, (8,9,10)]) - [1, 2, 3, 42, None, 4, 5, 6, 7, 8, 9, 10] - >>> flatten(["foo", "bar"]) - ['foo', 'bar'] - >>> flatten(["foo", ["baz", 42], "bar"]) - ['foo', 'baz', 42, 'bar'] - """ - warnings.warn( - "The flatten function is deprecated and will be removed in a future version of Scrapy.", - category=ScrapyDeprecationWarning, - stacklevel=2, - ) - return list(iflatten(x)) - - -def iflatten(x: Iterable[Any]) -> Iterable[Any]: - """iflatten(sequence) -> iterator - - Similar to ``.flatten()``, but returns iterator instead""" - warnings.warn( - "The iflatten function is deprecated and will be removed in a future version of Scrapy.", - category=ScrapyDeprecationWarning, - stacklevel=2, - ) - for el in x: - if is_listlike(el): - yield from iflatten(el) - else: - yield el +_P = ParamSpec("_P") def is_listlike(x: Any) -> bool: @@ -213,35 +169,46 @@ def binary_is_text(data: bytes) -> bool: return all(c not in _BINARYCHARS for c in data) -def get_func_args(func: Callable[..., Any], stripself: bool = False) -> list[str]: - """Return the argument name list of a callable object""" +def get_func_args_dict( + func: Callable[..., Any], stripself: bool = False +) -> Mapping[str, inspect.Parameter]: + """Return the argument dict of a callable object. + + .. versionadded:: VERSION + """ if not callable(func): raise TypeError(f"func must be callable, got '{type(func).__name__}'") - args: list[str] = [] + args: Mapping[str, inspect.Parameter] try: sig = inspect.signature(func) except ValueError: - return args + return {} if isinstance(func, partial): partial_args = func.args partial_kw = func.keywords + args = {} for name, param in sig.parameters.items(): - if param.name in partial_args: + if name in partial_args: continue - if partial_kw and param.name in partial_kw: + if partial_kw and name in partial_kw: continue - args.append(name) + args[name] = param else: - args = list(sig.parameters) + args = sig.parameters - if stripself and args and args[0] == "self": - args = args[1:] + if stripself and args and "self" in args: + args = {k: v for k, v in args.items() if k != "self"} return args +def get_func_args(func: Callable[..., Any], stripself: bool = False) -> list[str]: + """Return the argument name list of a callable object""" + return list(get_func_args_dict(func, stripself=stripself)) + + def get_spec(func: Callable[..., Any]) -> tuple[list[str], dict[str, Any]]: """Returns (args, kwargs) tuple for a function >>> import re @@ -275,35 +242,10 @@ def get_spec(func: Callable[..., Any]) -> tuple[list[str], dict[str, Any]]: firstdefault = len(spec.args) - len(defaults) args = spec.args[:firstdefault] - kwargs = dict(zip(spec.args[firstdefault:], defaults)) + kwargs = dict(zip(spec.args[firstdefault:], defaults, strict=False)) return args, kwargs -def equal_attributes( - obj1: Any, obj2: Any, attributes: list[str | Callable[[Any], Any]] | None -) -> bool: - """Compare two objects attributes""" - warnings.warn( - "The equal_attributes function is deprecated and will be removed in a future version of Scrapy.", - category=ScrapyDeprecationWarning, - stacklevel=2, - ) - # not attributes given return False by default - if not attributes: - return False - - temp1, temp2 = object(), object() - for attr in attributes: - # support callables like itemgetter - if callable(attr): - if attr(obj1) != attr(obj2): - return False - elif getattr(obj1, attr, temp1) != getattr(obj2, attr, temp2): - return False - # all attributes equal - return True - - @overload def without_none_values(iterable: Mapping[_KT, _VT]) -> dict[_KT, _VT]: ... diff --git a/scrapy/utils/reactor.py b/scrapy/utils/reactor.py index ce4c01731..f37c48a7d 100644 --- a/scrapy/utils/reactor.py +++ b/scrapy/utils/reactor.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio import sys from contextlib import suppress -from typing import TYPE_CHECKING, Any, Generic, TypeVar +from typing import TYPE_CHECKING, Any, Generic, ParamSpec, TypeVar from warnings import catch_warnings, filterwarnings from twisted.internet import asyncioreactor, error @@ -13,23 +13,20 @@ from scrapy.utils.misc import load_object from scrapy.utils.python import global_object_name if TYPE_CHECKING: - from asyncio import AbstractEventLoop, AbstractEventLoopPolicy + from asyncio import AbstractEventLoop from collections.abc import Callable from twisted.internet.protocol import ServerFactory from twisted.internet.tcp import Port - # typing.ParamSpec requires Python 3.10 - from typing_extensions import ParamSpec - from scrapy.utils.asyncio import CallLaterResult - _P = ParamSpec("_P") _T = TypeVar("_T") +_P = ParamSpec("_P") -def listen_tcp(portrange: list[int], host: str, factory: ServerFactory) -> Port: # type: ignore[return] # pylint: disable=inconsistent-return-statements +def listen_tcp(portrange: list[int], host: str, factory: ServerFactory) -> Port: # type: ignore[return] # pylint: disable=inconsistent-return-statements # noqa: RET503 """Like reactor.listenTCP but tries different ports in a range.""" from twisted.internet import reactor @@ -39,7 +36,7 @@ def listen_tcp(portrange: list[int], host: str, factory: ServerFactory) -> Port: return reactor.listenTCP(0, factory, interface=host) if len(portrange) == 1: return reactor.listenTCP(portrange[0], factory, interface=host) - for x in range(portrange[0], portrange[1] + 1): # noqa: RET503 + for x in range(portrange[0], portrange[1] + 1): try: return reactor.listenTCP(x, factory, interface=host) except error.CannotListenError: @@ -60,7 +57,8 @@ class CallLaterOnce(Generic[_T]): self._deferreds: list[Deferred] = [] def schedule(self, delay: float = 0) -> None: - from scrapy.utils.asyncio import call_later + # circular import + from scrapy.utils.asyncio import call_later # noqa: PLC0415 if self._call is None: self._call = call_later(delay, self) @@ -70,7 +68,8 @@ class CallLaterOnce(Generic[_T]): self._call.cancel() def __call__(self) -> _T: - from scrapy.utils.asyncio import call_later + # circular import + from scrapy.utils.asyncio import call_later # noqa: PLC0415 self._call = None result = self._func(*self._a, **self._kw) @@ -82,7 +81,8 @@ class CallLaterOnce(Generic[_T]): return result async def wait(self): - from scrapy.utils.defer import maybe_deferred_to_future + # circular import + from scrapy.utils.defer import maybe_deferred_to_future # noqa: PLC0415 d = Deferred() self._deferreds.append(d) @@ -97,17 +97,12 @@ def set_asyncio_event_loop_policy() -> None: so we restrict their use to the absolutely essential case. This should only be used to install the reactor. """ - _get_asyncio_event_loop_policy() - - -def _get_asyncio_event_loop_policy() -> AbstractEventLoopPolicy: policy = asyncio.get_event_loop_policy() if sys.platform == "win32" and not isinstance( policy, asyncio.WindowsSelectorEventLoopPolicy ): policy = asyncio.WindowsSelectorEventLoopPolicy() asyncio.set_event_loop_policy(policy) - return policy def install_reactor(reactor_path: str, event_loop_path: str | None = None) -> None: diff --git a/scrapy/utils/request.py b/scrapy/utils/request.py index 9c1161968..616ab661f 100644 --- a/scrapy/utils/request.py +++ b/scrapy/utils/request.py @@ -7,16 +7,13 @@ from __future__ import annotations import hashlib import json -import warnings from typing import TYPE_CHECKING, Any, Protocol from urllib.parse import urlunparse from weakref import WeakKeyDictionary -from w3lib.http import basic_auth_header from w3lib.url import canonicalize_url from scrapy import Request, Spider -from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.misc import load_object from scrapy.utils.python import to_bytes, to_unicode @@ -120,41 +117,12 @@ class RequestFingerprinter: return cls(crawler) def __init__(self, crawler: Crawler | None = None): - if crawler: - implementation = crawler.settings.get( - "REQUEST_FINGERPRINTER_IMPLEMENTATION" - ) - else: - implementation = "SENTINEL" - - if implementation != "SENTINEL": - message = ( - "'REQUEST_FINGERPRINTER_IMPLEMENTATION' is a deprecated setting.\n" - "It will be removed in a future version of Scrapy." - ) - warnings.warn(message, category=ScrapyDeprecationWarning, stacklevel=2) self._fingerprint = fingerprint def fingerprint(self, request: Request) -> bytes: return self._fingerprint(request) -def request_authenticate( - request: Request, - username: str, - password: str, -) -> None: - """Authenticate the given request (in place) using the HTTP basic access - authentication mechanism (RFC 2617) and the given username and password - """ - warnings.warn( - "The request_authenticate function is deprecated and will be removed in a future version of Scrapy.", - category=ScrapyDeprecationWarning, - stacklevel=2, - ) - request.headers["Authorization"] = basic_auth_header(username, password) - - def request_httprepr(request: Request) -> bytes: """Return the raw HTTP representation (as bytes) of the given request. This is provided only for reference since it's not the actual stream of diff --git a/scrapy/utils/response.py b/scrapy/utils/response.py index 76a6b7de6..8b2417bfb 100644 --- a/scrapy/utils/response.py +++ b/scrapy/utils/response.py @@ -48,7 +48,7 @@ def get_meta_refresh( if response not in _metaref_cache: text = response.text[0:4096] _metaref_cache[response] = html.get_meta_refresh( - text, response.url, response.encoding, ignore_tags=ignore_tags + text, get_base_url(response), response.encoding, ignore_tags=ignore_tags ) return _metaref_cache[response] @@ -91,7 +91,8 @@ def open_in_browser( if "item name" not in response.body: open_in_browser(response) """ - from scrapy.http import HtmlResponse, TextResponse + # circular imports + from scrapy.http import HtmlResponse, TextResponse # noqa: PLC0415 # XXX: this implementation is a bit dirty and could be improved body = response.body diff --git a/scrapy/utils/serialize.py b/scrapy/utils/serialize.py index bcfae0c00..5d06bbe30 100644 --- a/scrapy/utils/serialize.py +++ b/scrapy/utils/serialize.py @@ -1,13 +1,11 @@ import datetime import decimal import json -import warnings from typing import Any from itemadapter import ItemAdapter, is_item from twisted.internet import defer -from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.http import Request, Response @@ -35,13 +33,3 @@ class ScrapyJSONEncoder(json.JSONEncoder): if is_item(o): return ItemAdapter(o).asdict() return super().default(o) - - -class ScrapyJSONDecoder(json.JSONDecoder): - def __init__(self, *args, **kwargs): - warnings.warn( - "The ScrapyJSONDecoder class is deprecated and will be removed in a future version of Scrapy.", - category=ScrapyDeprecationWarning, - stacklevel=2, - ) - super().__init__(*args, **kwargs) diff --git a/scrapy/utils/signal.py b/scrapy/utils/signal.py index 552fbaa90..1b890933b 100644 --- a/scrapy/utils/signal.py +++ b/scrapy/utils/signal.py @@ -120,6 +120,8 @@ async def send_catch_log_async( `. Returns a coroutine that completes once all signal handlers have finished. + + .. versionadded:: VERSION """ return await maybe_deferred_to_future( send_catch_log_deferred(signal, sender, *arguments, **named) diff --git a/scrapy/utils/sitemap.py b/scrapy/utils/sitemap.py index e0d9f4595..c0100ea62 100644 --- a/scrapy/utils/sitemap.py +++ b/scrapy/utils/sitemap.py @@ -24,7 +24,7 @@ class Sitemap: xmlp = lxml.etree.XMLParser( recover=True, remove_comments=True, resolve_entities=False ) - self._root = lxml.etree.fromstring(xmltext, parser=xmlp) # noqa: S320 + self._root = lxml.etree.fromstring(xmltext, parser=xmlp) rt = self._root.tag assert isinstance(rt, str) self.type = rt.split("}", 1)[1] if "}" in rt else rt diff --git a/scrapy/utils/spider.py b/scrapy/utils/spider.py index 74fd0e354..75d6c9bb0 100644 --- a/scrapy/utils/spider.py +++ b/scrapy/utils/spider.py @@ -2,7 +2,7 @@ from __future__ import annotations import inspect import logging -from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload +from typing import TYPE_CHECKING, Any, TypeVar, overload from scrapy.spiders import Spider from scrapy.utils.defer import deferred_from_coro @@ -76,7 +76,7 @@ def spidercls_for_request( def spidercls_for_request( spider_loader: SpiderLoaderProtocol, request: Request, - default_spidercls: Literal[None], + default_spidercls: None, log_none: bool = ..., log_multiple: bool = ..., ) -> type[Spider] | None: ... diff --git a/scrapy/utils/test.py b/scrapy/utils/test.py index 3780ad23e..4e0356350 100644 --- a/scrapy/utils/test.py +++ b/scrapy/utils/test.py @@ -7,14 +7,17 @@ from __future__ import annotations import asyncio import os import warnings +from ftplib import FTP from importlib import import_module from pathlib import Path from posixpath import split from typing import TYPE_CHECKING, Any, TypeVar, cast -from unittest import TestCase, mock +from unittest import mock from twisted.trial.unittest import SkipTest +from twisted.web.client import Agent +from scrapy.crawler import CrawlerRunner from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.utils.boto import is_botocore_available from scrapy.utils.deprecate import create_deprecated_class @@ -59,7 +62,7 @@ def skip_if_no_boto() -> None: def get_gcs_content_and_delete( bucket: Any, path: str ) -> tuple[bytes, list[dict[str, str]], Any]: - from google.cloud import storage + from google.cloud import storage # noqa: PLC0415 warnings.warn( "The get_gcs_content_and_delete() function is deprecated and will be removed in a future version of Scrapy.", @@ -83,8 +86,6 @@ def get_ftp_content_and_delete( password: str, use_active_mode: bool = False, ) -> bytes: - from ftplib import FTP - warnings.warn( "The get_ftp_content_and_delete() function is deprecated and will be removed in a future version of Scrapy.", category=ScrapyDeprecationWarning, @@ -137,8 +138,6 @@ def get_crawler( will be used to populate the crawler settings with a project level priority. """ - from scrapy.crawler import CrawlerRunner - # When needed, useful settings can be added here, e.g. ones that prevent # deprecation warnings. settings: dict[str, Any] = { @@ -167,20 +166,6 @@ def get_testenv() -> dict[str, str]: return env -def assert_samelines( - testcase: TestCase, text1: str, text2: str, msg: str | None = None -) -> None: - """Asserts text1 and text2 have the same lines, ignoring differences in - line endings between platforms - """ - warnings.warn( - "The assert_samelines function is deprecated and will be removed in a future version of Scrapy.", - category=ScrapyDeprecationWarning, - stacklevel=2, - ) - testcase.assertEqual(text1.splitlines(), text2.splitlines(), msg) # noqa: PT009 - - def get_from_asyncio_queue(value: _T) -> Awaitable[_T]: q: asyncio.Queue[_T] = asyncio.Queue() getter = q.get() @@ -192,7 +177,7 @@ def mock_google_cloud_storage() -> tuple[Any, Any, Any]: """Creates autospec mocks for google-cloud-storage Client, Bucket and Blob classes and set their proper return values. """ - from google.cloud.storage import Blob, Bucket, Client + from google.cloud.storage import Blob, Bucket, Client # noqa: PLC0415 warnings.warn( "The mock_google_cloud_storage() function is deprecated and will be removed in a future version of Scrapy.", @@ -213,7 +198,6 @@ def mock_google_cloud_storage() -> tuple[Any, Any, Any]: def get_web_client_agent_req(url: str) -> Deferred[TxResponse]: from twisted.internet import reactor - from twisted.web.client import Agent # imports twisted.internet.reactor agent = Agent(reactor) return cast("Deferred[TxResponse]", agent.request(b"GET", url.encode("utf-8"))) diff --git a/scrapy/utils/testproc.py b/scrapy/utils/testproc.py index 10f764ab8..ce26b7f92 100644 --- a/scrapy/utils/testproc.py +++ b/scrapy/utils/testproc.py @@ -6,7 +6,6 @@ import warnings from typing import TYPE_CHECKING, cast from twisted.internet.defer import Deferred -from twisted.internet.error import ProcessTerminated from twisted.internet.protocol import ProcessProtocol from scrapy.exceptions import ScrapyDeprecationWarning @@ -14,6 +13,7 @@ from scrapy.exceptions import ScrapyDeprecationWarning if TYPE_CHECKING: from collections.abc import Iterable + from twisted.internet.error import ProcessTerminated from twisted.python.failure import Failure @@ -55,7 +55,7 @@ class ProcessTest: msg += "\n" msg += f"\n>>> stderr <<<\n{pp.err.decode()}" raise RuntimeError(msg) - return cast(int, pp.exitcode), pp.out, pp.err + return cast("int", pp.exitcode), pp.out, pp.err class TestProcessProtocol(ProcessProtocol): @@ -72,5 +72,5 @@ class TestProcessProtocol(ProcessProtocol): self.err += data def processEnded(self, status: Failure) -> None: - self.exitcode = cast(ProcessTerminated, status.value).exitCode + self.exitcode = cast("ProcessTerminated", status.value).exitCode self.deferred.callback(self) diff --git a/scrapy/utils/url.py b/scrapy/utils/url.py index 1348cc992..8cfa5cce4 100644 --- a/scrapy/utils/url.py +++ b/scrapy/utils/url.py @@ -8,7 +8,7 @@ from __future__ import annotations import re import warnings from importlib import import_module -from typing import TYPE_CHECKING, Union +from typing import TYPE_CHECKING, TypeAlias from urllib.parse import ParseResult, urldefrag, urlparse, urlunparse from warnings import warn @@ -37,7 +37,7 @@ if TYPE_CHECKING: from scrapy import Spider -UrlT = Union[str, bytes, ParseResult] +UrlT: TypeAlias = str | bytes | ParseResult def url_is_from_any_domain(url: UrlT, domains: Iterable[str]) -> bool: diff --git a/sep/sep-008.rst b/sep/sep-008.rst index 1c38b1c40..baf1fde23 100644 --- a/sep/sep-008.rst +++ b/sep/sep-008.rst @@ -37,8 +37,8 @@ Modules and classes - ``scrapy.contrib.itemparser.ItemParser`` - ``scrapy.contrib.itemparser.XPathItemParser`` -- ``scrapy.contrib.itemparser.parsers.``MapConcat`` *(ex. ``TreeExpander``)* -- ``scrapy.contrib.itemparser.parsers.``TakeFirst`` +- ``scrapy.contrib.itemparser.parsers.MapConcat`` *(ex. ``TreeExpander``)* +- ``scrapy.contrib.itemparser.parsers.TakeFirst`` - ``scrapy.contrib.itemparser.parsers.Join`` - ``scrapy.contrib.itemparser.parsers.Identity`` @@ -70,7 +70,7 @@ Alternative Public API Proposal - ``ItemLoader.replace_value()`` - ``ItemLoader.load_item()`` *(returns loaded item)* -- ``ItemLoader.get_stored_values()`` or ``ItemLoader.get_values()`` *(returns the ``ItemLoader values)* +- ``ItemLoader.get_stored_values()`` or ``ItemLoader.get_values()`` *(returns the ``ItemLoader`` values)* - ``ItemLoader.get_output_value()`` - ``ItemLoader.get_input_processor()`` or ``ItemLoader.get_in_processor()`` *(short version)* diff --git a/tests/AsyncCrawlerProcess/asyncio_deferred_signal.py b/tests/AsyncCrawlerProcess/asyncio_deferred_signal.py index c32aaf37d..e000a23db 100644 --- a/tests/AsyncCrawlerProcess/asyncio_deferred_signal.py +++ b/tests/AsyncCrawlerProcess/asyncio_deferred_signal.py @@ -16,7 +16,7 @@ class UppercasePipeline: def open_spider(self, spider): return deferred_from_coro(self._open_spider(spider)) - def process_item(self, item, spider): + def process_item(self, item): return {"url": item["url"].upper()} diff --git a/tests/AsyncCrawlerProcess/twisted_reactor_custom_settings_select.py b/tests/AsyncCrawlerProcess/twisted_reactor_custom_settings_select.py index 68239e651..f6fead718 100644 --- a/tests/AsyncCrawlerProcess/twisted_reactor_custom_settings_select.py +++ b/tests/AsyncCrawlerProcess/twisted_reactor_custom_settings_select.py @@ -21,7 +21,7 @@ def log_task_exception(task: Task) -> None: try: task.result() except Exception: - logging.exception("Crawl task failed") + logging.exception("Crawl task failed") # noqa: LOG015 process = AsyncCrawlerProcess() diff --git a/tests/CrawlerProcess/asyncio_deferred_signal.py b/tests/CrawlerProcess/asyncio_deferred_signal.py index 028e3a08a..eb2e1105c 100644 --- a/tests/CrawlerProcess/asyncio_deferred_signal.py +++ b/tests/CrawlerProcess/asyncio_deferred_signal.py @@ -16,7 +16,7 @@ class UppercasePipeline: def open_spider(self, spider): return deferred_from_coro(self._open_spider(spider)) - def process_item(self, item, spider): + def process_item(self, item): return {"url": item["url"].upper()} diff --git a/tests/CrawlerRunner/ip_address.py b/tests/CrawlerRunner/ip_address.py index 207fc86ad..9811bf90c 100644 --- a/tests/CrawlerRunner/ip_address.py +++ b/tests/CrawlerRunner/ip_address.py @@ -1,10 +1,11 @@ # ruff: noqa: E402 from scrapy.utils.reactor import install_reactor +from tests.mockserver.dns import MockDNSServer +from tests.mockserver.http import MockServer install_reactor("twisted.internet.asyncioreactor.AsyncioSelectorReactor") -from urllib.parse import urlparse from twisted.names import cache, resolve from twisted.names import hosts as hostsModule @@ -15,7 +16,6 @@ from scrapy import Request, Spider from scrapy.crawler import CrawlerRunner from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.log import configure_logging -from tests.mockserver import MockDNSServer, MockServer # https://stackoverflow.com/a/32784190 @@ -46,7 +46,7 @@ if __name__ == "__main__": from twisted.internet import reactor with MockServer() as mock_http_server, MockDNSServer() as mock_dns_server: - port = urlparse(mock_http_server.http_address).port + port = mock_http_server.http_port url = f"http://not.a.real.domain:{port}/echo" servers = [(mock_dns_server.host, mock_dns_server.port)] diff --git a/tests/ftpserver.py b/tests/ftpserver.py deleted file mode 100644 index 0c6ca3621..000000000 --- a/tests/ftpserver.py +++ /dev/null @@ -1,24 +0,0 @@ -from argparse import ArgumentParser - -from pyftpdlib.authorizers import DummyAuthorizer -from pyftpdlib.handlers import FTPHandler -from pyftpdlib.servers import FTPServer - - -def main(): - parser = ArgumentParser() - parser.add_argument("-d", "--directory") - args = parser.parse_args() - - authorizer = DummyAuthorizer() - full_permissions = "elradfmwMT" - authorizer.add_anonymous(args.directory, perm=full_permissions) - handler = FTPHandler - handler.authorizer = authorizer - address = ("127.0.0.1", 2121) - server = FTPServer(address, handler) - server.serve_forever() - - -if __name__ == "__main__": - main() diff --git a/tests/keys/example-com.gen.README b/tests/keys/example-com.gen.README index 955e7b057..026226b2c 100644 --- a/tests/keys/example-com.gen.README +++ b/tests/keys/example-com.gen.README @@ -21,4 +21,3 @@ Locality Name (eg, city) [New York]:The Internet Organization Name (eg, company) [Example, LLC]:Scrapy Common Name (e.g. server FQDN or YOUR name) [Example Company]:www.example.com Email Address [test@example.com]: - diff --git a/tests/keys/localhost-ip.gen.README b/tests/keys/localhost-ip.gen.README index 8e94e1217..6424ad83e 100644 --- a/tests/keys/localhost-ip.gen.README +++ b/tests/keys/localhost-ip.gen.README @@ -18,4 +18,3 @@ Organization Name (eg, company) [Internet Widgits Pty Ltd]:Scrapy Organizational Unit Name (eg, section) []:. Common Name (e.g. server FQDN or YOUR name) []:127.0.0.1 Email Address []:. - diff --git a/tests/keys/localhost.gen.README b/tests/keys/localhost.gen.README index 19c29a725..65f3a2d5c 100644 --- a/tests/keys/localhost.gen.README +++ b/tests/keys/localhost.gen.README @@ -18,4 +18,3 @@ Organization Name (eg, company) [Internet Widgits Pty Ltd]:Scrapy Organizational Unit Name (eg, section) []:. Common Name (e.g. server FQDN or YOUR name) []:localhost Email Address []:. - diff --git a/tests/mocks/dummydbm.py b/tests/mocks/dummydbm.py index a7f7f1356..e358eaca4 100644 --- a/tests/mocks/dummydbm.py +++ b/tests/mocks/dummydbm.py @@ -17,7 +17,7 @@ error = KeyError _DATABASES: defaultdict[Any, DummyDB] = defaultdict(DummyDB) -def open(file, flag="r", mode=0o666): +def open(file, flag="r", mode=0o666): # noqa: A001 """Open or create a dummy database compatible. Arguments ``flag`` and ``mode`` are ignored. diff --git a/tests/mockserver.py b/tests/mockserver.py deleted file mode 100644 index 841a2cfe4..000000000 --- a/tests/mockserver.py +++ /dev/null @@ -1,436 +0,0 @@ -from __future__ import annotations - -import argparse -import json -import os -import random -import sys -from pathlib import Path -from shutil import rmtree -from subprocess import PIPE, Popen -from tempfile import mkdtemp -from typing import TYPE_CHECKING -from urllib.parse import urlencode - -from OpenSSL import SSL -from twisted.internet import defer, ssl -from twisted.internet.task import deferLater -from twisted.names import dns, error -from twisted.names.server import DNSServerFactory -from twisted.web import resource, server -from twisted.web.server import NOT_DONE_YET, Site -from twisted.web.static import Data, File -from twisted.web.util import Redirect, redirectTo - -from scrapy.utils.python import to_bytes, to_unicode -from tests import tests_datadir - -if TYPE_CHECKING: - from twisted.internet.protocol import ServerFactory - - -def getarg(request, name, default=None, type=None): - if name in request.args: - value = request.args[name][0] - if type is not None: - value = type(value) - return value - return default - - -def get_mockserver_env() -> dict[str, str]: - """Return a OS environment dict suitable to run mockserver processes.""" - - tests_path = Path(__file__).parent.parent - pythonpath = str(tests_path) + os.pathsep + os.environ.get("PYTHONPATH", "") - env = os.environ.copy() - env["PYTHONPATH"] = pythonpath - return env - - -# most of the following resources are copied from twisted.web.test.test_webclient -class ForeverTakingResource(resource.Resource): - """ - L{ForeverTakingResource} is a resource which never finishes responding - to requests. - """ - - def __init__(self, write=False): - resource.Resource.__init__(self) - self._write = write - - def render(self, request): - if self._write: - request.write(b"some bytes") - return server.NOT_DONE_YET - - -class ErrorResource(resource.Resource): - def render(self, request): - request.setResponseCode(401) - if request.args.get(b"showlength"): - request.setHeader(b"content-length", b"0") - return b"" - - -class NoLengthResource(resource.Resource): - def render(self, request): - return b"nolength" - - -class HostHeaderResource(resource.Resource): - """ - A testing resource which renders itself as the value of the host header - from the request. - """ - - def render(self, request): - return request.requestHeaders.getRawHeaders(b"host")[0] - - -class PayloadResource(resource.Resource): - """ - A testing resource which renders itself as the contents of the request body - as long as the request body is 100 bytes long, otherwise which renders - itself as C{"ERROR"}. - """ - - def render(self, request): - data = request.content.read() - contentLength = request.requestHeaders.getRawHeaders(b"content-length")[0] - if len(data) != 100 or int(contentLength) != 100: - return b"ERROR" - return data - - -class BrokenDownloadResource(resource.Resource): - def render(self, request): - # only sends 3 bytes even though it claims to send 5 - request.setHeader(b"content-length", b"5") - request.write(b"abc") - return b"" - - -class LeafResource(resource.Resource): - isLeaf = True - - def deferRequest(self, request, delay, f, *a, **kw): - from twisted.internet import reactor - - def _cancelrequest(_): - # silence CancelledError - d.addErrback(lambda _: None) - d.cancel() - - d = deferLater(reactor, delay, f, *a, **kw) - request.notifyFinish().addErrback(_cancelrequest) - return d - - -class Follow(LeafResource): - def render(self, request): - total = getarg(request, b"total", 100, type=int) - show = getarg(request, b"show", 1, type=int) - order = getarg(request, b"order", b"desc") - maxlatency = getarg(request, b"maxlatency", 0, type=float) - n = getarg(request, b"n", total, type=int) - if order == b"rand": - nlist = [random.randint(1, total) for _ in range(show)] - else: # order == "desc" - nlist = range(n, max(n - show, 0), -1) - - lag = random.random() * maxlatency - self.deferRequest(request, lag, self.renderRequest, request, nlist) - return NOT_DONE_YET - - def renderRequest(self, request, nlist): - s = """ """ - args = request.args.copy() - for nl in nlist: - args[b"n"] = [to_bytes(str(nl))] - argstr = urlencode(args, doseq=True) - s += f"follow {nl}
" - s += """""" - request.write(to_bytes(s)) - request.finish() - - -class Delay(LeafResource): - def render_GET(self, request): - n = getarg(request, b"n", 1, type=float) - b = getarg(request, b"b", 1, type=int) - if b: - # send headers now and delay body - request.write("") - self.deferRequest(request, n, self._delayedRender, request, n) - return NOT_DONE_YET - - def _delayedRender(self, request, n): - request.write(to_bytes(f"Response delayed for {n:.3f} seconds\n")) - request.finish() - - -class Status(LeafResource): - def render_GET(self, request): - n = getarg(request, b"n", 200, type=int) - request.setResponseCode(n) - return b"" - - -class Raw(LeafResource): - def render_GET(self, request): - request.startedWriting = 1 - self.deferRequest(request, 0, self._delayedRender, request) - return NOT_DONE_YET - - render_POST = render_GET - - def _delayedRender(self, request): - raw = getarg(request, b"raw", b"HTTP 1.1 200 OK\n") - request.startedWriting = 1 - request.write(raw) - request.channel.transport.loseConnection() - request.finish() - - -class Echo(LeafResource): - def render_GET(self, request): - output = { - "headers": { - to_unicode(k): [to_unicode(v) for v in vs] - for k, vs in request.requestHeaders.getAllRawHeaders() - }, - "body": to_unicode(request.content.read()), - } - return to_bytes(json.dumps(output)) - - render_POST = render_GET - - -class RedirectTo(LeafResource): - def render(self, request): - goto = getarg(request, b"goto", b"/") - # we force the body content, otherwise Twisted redirectTo() - # returns HTML with bytes: - content = Redirect.render(self, request) - return content.replace( - b'http-equiv="refresh"', b'http-no-equiv="do-not-refresh-me"' - ) - - -class Root(resource.Resource): - def __init__(self): - resource.Resource.__init__(self) - self.putChild(b"status", Status()) - self.putChild(b"follow", Follow()) - self.putChild(b"delay", Delay()) - self.putChild(b"partial", Partial()) - self.putChild(b"drop", Drop()) - self.putChild(b"raw", Raw()) - self.putChild(b"echo", Echo()) - self.putChild(b"payload", PayloadResource()) - self.putChild(b"alpayload", ArbitraryLengthPayloadResource()) - self.putChild(b"files", File(str(Path(tests_datadir, "test_site/files/")))) - self.putChild(b"redirect-to", RedirectTo()) - self.putChild(b"text", Data(b"Works", "text/plain")) - self.putChild( - b"html", - Data( - b"

Works

World

", - "text/html", - ), - ) - self.putChild( - b"enc-gb18030", - Data(b"

gb18030 encoding

", "text/html; charset=gb18030"), - ) - self.putChild(b"redirect", Redirect(b"/redirected")) - self.putChild( - b"redirect-no-meta-refresh", NoMetaRefreshRedirect(b"/redirected") - ) - self.putChild(b"redirected", Data(b"Redirected here", "text/plain")) - - def getChild(self, name, request): - return self - - def render(self, request): - return b"Scrapy mock HTTP server\n" - - -class MockServer: - def __enter__(self): - self.proc = Popen( - [sys.executable, "-u", "-m", "tests.mockserver", "-t", "http"], - stdout=PIPE, - env=get_mockserver_env(), - ) - http_address = self.proc.stdout.readline().strip().decode("ascii") - https_address = self.proc.stdout.readline().strip().decode("ascii") - - self.http_address = http_address - self.https_address = https_address - - return self - - def __exit__(self, exc_type, exc_value, traceback): - self.proc.kill() - self.proc.communicate() - - def url(self, path, is_secure=False): - host = self.https_address if is_secure else self.http_address - host = host.replace("0.0.0.0", "127.0.0.1") - return host + path - - -class MockDNSResolver: - """ - Implements twisted.internet.interfaces.IResolver partially - """ - - def _resolve(self, name): - record = dns.Record_A(address=b"127.0.0.1") - answer = dns.RRHeader(name=name, payload=record) - return [answer], [], [] - - def query(self, query, timeout=None): - if query.type == dns.A: - return defer.succeed(self._resolve(query.name.name)) - return defer.fail(error.DomainError()) - - def lookupAllRecords(self, name, timeout=None): - return defer.succeed(self._resolve(name)) - - -class MockDNSServer: - def __enter__(self): - self.proc = Popen( - [sys.executable, "-u", "-m", "tests.mockserver", "-t", "dns"], - stdout=PIPE, - env=get_mockserver_env(), - ) - self.host = "127.0.0.1" - self.port = int( - self.proc.stdout.readline().strip().decode("ascii").split(":")[1] - ) - return self - - def __exit__(self, exc_type, exc_value, traceback): - self.proc.kill() - self.proc.communicate() - - -class MockFTPServer: - """Creates an FTP server on port 2121 with a default passwordless user - (anonymous) and a temporary root path that you can read from the - :attr:`path` attribute.""" - - def __enter__(self): - self.path = Path(mkdtemp()) - self.proc = Popen( - [sys.executable, "-u", "-m", "tests.ftpserver", "-d", str(self.path)], - stderr=PIPE, - env=get_mockserver_env(), - ) - for line in self.proc.stderr: - if b"starting FTP server" in line: - break - return self - - def __exit__(self, exc_type, exc_value, traceback): - rmtree(str(self.path)) - self.proc.kill() - self.proc.communicate() - - def url(self, path): - return "ftp://127.0.0.1:2121/" + path - - -def ssl_context_factory( - keyfile="keys/localhost.key", certfile="keys/localhost.crt", cipher_string=None -): - factory = ssl.DefaultOpenSSLContextFactory( - str(Path(__file__).parent / keyfile), - str(Path(__file__).parent / certfile), - ) - if cipher_string: - ctx = factory.getContext() - # disabling TLS1.3 because it unconditionally enables some strong ciphers - ctx.set_options(SSL.OP_CIPHER_SERVER_PREFERENCE | SSL.OP_NO_TLSv1_3) - ctx.set_cipher_list(to_bytes(cipher_string)) - return factory - - -if __name__ == "__main__": - from twisted.internet import reactor - - parser = argparse.ArgumentParser() - parser.add_argument( - "-t", "--type", type=str, choices=("http", "dns"), default="http" - ) - args = parser.parse_args() - - factory: ServerFactory - - if args.type == "http": - root = Root() - factory = Site(root) - httpPort = reactor.listenTCP(0, factory) - contextFactory = ssl_context_factory() - httpsPort = reactor.listenSSL(0, factory, contextFactory) - - def print_listening(): - httpHost = httpPort.getHost() - httpsHost = httpsPort.getHost() - httpAddress = f"http://{httpHost.host}:{httpHost.port}" - httpsAddress = f"https://{httpsHost.host}:{httpsHost.port}" - print(httpAddress) - print(httpsAddress) - - elif args.type == "dns": - clients = [MockDNSResolver()] - factory = DNSServerFactory(clients=clients) - protocol = dns.DNSDatagramProtocol(controller=factory) - listener = reactor.listenUDP(0, protocol) - - def print_listening(): - host = listener.getHost() - print(f"{host.host}:{host.port}") - - reactor.callWhenRunning(print_listening) - reactor.run() diff --git a/tests/mockserver/__init__.py b/tests/mockserver/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/mockserver/dns.py b/tests/mockserver/dns.py new file mode 100644 index 000000000..7c08f237c --- /dev/null +++ b/tests/mockserver/dns.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import sys +from subprocess import PIPE, Popen + +from twisted.internet import defer +from twisted.names import dns, error +from twisted.names.server import DNSServerFactory + +from tests.utils import get_script_run_env + + +class MockDNSResolver: + """ + Implements twisted.internet.interfaces.IResolver partially + """ + + def _resolve(self, name): + record = dns.Record_A(address=b"127.0.0.1") + answer = dns.RRHeader(name=name, payload=record) + return [answer], [], [] + + def query(self, query, timeout=None): + if query.type == dns.A: + return defer.succeed(self._resolve(query.name.name)) + return defer.fail(error.DomainError()) + + def lookupAllRecords(self, name, timeout=None): + return defer.succeed(self._resolve(name)) + + +class MockDNSServer: + def __enter__(self): + self.proc = Popen( + [sys.executable, "-u", "-m", "tests.mockserver.dns"], + stdout=PIPE, + env=get_script_run_env(), + ) + self.host = "127.0.0.1" + self.port = int( + self.proc.stdout.readline().strip().decode("ascii").split(":")[1] + ) + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.proc.kill() + self.proc.communicate() + + +def main() -> None: + from twisted.internet import reactor + + clients = [MockDNSResolver()] + factory = DNSServerFactory(clients=clients) + protocol = dns.DNSDatagramProtocol(controller=factory) + listener = reactor.listenUDP(0, protocol) + + def print_listening(): + host = listener.getHost() + print(f"{host.host}:{host.port}") + + reactor.callWhenRunning(print_listening) + reactor.run() + + +if __name__ == "__main__": + main() diff --git a/tests/mockserver/ftp.py b/tests/mockserver/ftp.py new file mode 100644 index 000000000..5505ac258 --- /dev/null +++ b/tests/mockserver/ftp.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import sys +from argparse import ArgumentParser +from pathlib import Path +from shutil import rmtree +from subprocess import PIPE, Popen +from tempfile import mkdtemp + +from pyftpdlib.authorizers import DummyAuthorizer +from pyftpdlib.handlers import FTPHandler +from pyftpdlib.servers import FTPServer + +from tests.utils import get_script_run_env + + +class MockFTPServer: + """Creates an FTP server on port 2121 with a default passwordless user + (anonymous) and a temporary root path that you can read from the + :attr:`path` attribute.""" + + def __enter__(self): + self.path = Path(mkdtemp()) + self.proc = Popen( + [sys.executable, "-u", "-m", "tests.mockserver.ftp", "-d", str(self.path)], + stderr=PIPE, + env=get_script_run_env(), + ) + for line in self.proc.stderr: + if b"starting FTP server" in line: + break + return self + + def __exit__(self, exc_type, exc_value, traceback): + rmtree(str(self.path)) + self.proc.kill() + self.proc.communicate() + + def url(self, path): + return "ftp://127.0.0.1:2121/" + path + + +def main() -> None: + parser = ArgumentParser() + parser.add_argument("-d", "--directory") + args = parser.parse_args() + + authorizer = DummyAuthorizer() + full_permissions = "elradfmwMT" + authorizer.add_anonymous(args.directory, perm=full_permissions) + handler = FTPHandler + handler.authorizer = authorizer + address = ("127.0.0.1", 2121) + server = FTPServer(address, handler) + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/tests/mockserver/http.py b/tests/mockserver/http.py new file mode 100644 index 000000000..d7c892828 --- /dev/null +++ b/tests/mockserver/http.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from pathlib import Path + +from twisted.web import resource +from twisted.web.static import Data, File +from twisted.web.util import Redirect + +from tests import tests_datadir + +from .http_base import BaseMockServer, main_factory +from .http_resources import ( + ArbitraryLengthPayloadResource, + BrokenChunkedResource, + BrokenDownloadResource, + ChunkedResource, + Compress, + ContentLengthHeaderResource, + Delay, + Drop, + DuplicateHeaderResource, + Echo, + EmptyContentTypeHeaderResource, + Follow, + ForeverTakingResource, + HostHeaderResource, + LargeChunkedFileResource, + NoMetaRefreshRedirect, + Partial, + PayloadResource, + Raw, + RedirectTo, + ResponseHeadersResource, + SetCookie, + Status, +) + + +class Root(resource.Resource): + def __init__(self): + super().__init__() + self.putChild(b"status", Status()) + self.putChild(b"follow", Follow()) + self.putChild(b"delay", Delay()) + self.putChild(b"partial", Partial()) + self.putChild(b"drop", Drop()) + self.putChild(b"raw", Raw()) + self.putChild(b"echo", Echo()) + self.putChild(b"payload", PayloadResource()) + self.putChild(b"alpayload", ArbitraryLengthPayloadResource()) + self.putChild(b"static", File(str(Path(tests_datadir, "test_site/")))) + self.putChild(b"redirect-to", RedirectTo()) + self.putChild(b"text", Data(b"Works", "text/plain")) + self.putChild( + b"html", + Data( + b"

Works

World

", + "text/html", + ), + ) + self.putChild( + b"enc-gb18030", + Data(b"

gb18030 encoding

", "text/html; charset=gb18030"), + ) + self.putChild(b"redirect", Redirect(b"/redirected")) + self.putChild( + b"redirect-no-meta-refresh", NoMetaRefreshRedirect(b"/redirected") + ) + self.putChild(b"redirected", Data(b"Redirected here", "text/plain")) + numbers = [str(x).encode("utf8") for x in range(2**18)] + self.putChild(b"numbers", Data(b"".join(numbers), "text/plain")) + self.putChild(b"wait", ForeverTakingResource()) + self.putChild(b"hang-after-headers", ForeverTakingResource(write=True)) + self.putChild(b"host", HostHeaderResource()) + self.putChild(b"broken", BrokenDownloadResource()) + self.putChild(b"chunked", ChunkedResource()) + self.putChild(b"broken-chunked", BrokenChunkedResource()) + self.putChild(b"contentlength", ContentLengthHeaderResource()) + self.putChild(b"nocontenttype", EmptyContentTypeHeaderResource()) + self.putChild(b"largechunkedfile", LargeChunkedFileResource()) + self.putChild(b"compress", Compress()) + self.putChild(b"duplicate-header", DuplicateHeaderResource()) + self.putChild(b"response-headers", ResponseHeadersResource()) + self.putChild(b"set-cookie", SetCookie()) + + def getChild(self, name, request): + return self + + def render(self, request): + return b"Scrapy mock HTTP server\n" + + +class MockServer(BaseMockServer): + module_name = "tests.mockserver.http" + + +main = main_factory(Root) + + +if __name__ == "__main__": + main() diff --git a/tests/mockserver/http_base.py b/tests/mockserver/http_base.py new file mode 100644 index 000000000..440d38fb9 --- /dev/null +++ b/tests/mockserver/http_base.py @@ -0,0 +1,132 @@ +"""Base classes and functions for HTTP mockservers.""" + +from __future__ import annotations + +import argparse +import sys +from abc import ABC, abstractmethod +from subprocess import PIPE, Popen +from typing import TYPE_CHECKING +from urllib.parse import urlparse + +from twisted.web.server import Site + +from tests.utils import get_script_run_env + +from .utils import ssl_context_factory + +if TYPE_CHECKING: + from collections.abc import Callable + + from twisted.web import resource + + +class BaseMockServer(ABC): + listen_http: bool = True + listen_https: bool = True + + @property + @abstractmethod + def module_name(self) -> str: + raise NotImplementedError + + def __init__(self) -> None: + if not self.listen_http and not self.listen_https: + raise ValueError("At least one of listen_http and listen_https must be set") + + self.proc: Popen | None = None + self.host: str = "127.0.0.1" + self.http_port: int | None = None + self.https_port: int | None = None + + def __enter__(self): + self.proc = Popen( + [sys.executable, "-u", "-m", self.module_name, *self.get_additional_args()], + stdout=PIPE, + env=get_script_run_env(), + ) + if self.listen_http: + http_address = self.proc.stdout.readline().strip().decode("ascii") + http_parsed = urlparse(http_address) + self.http_port = http_parsed.port + if self.listen_https: + https_address = self.proc.stdout.readline().strip().decode("ascii") + https_parsed = urlparse(https_address) + self.https_port = https_parsed.port + return self + + def __exit__(self, exc_type, exc_value, traceback): + if self.proc: + self.proc.kill() + self.proc.communicate() + + def get_additional_args(self) -> list[str]: + return [] + + def port(self, is_secure: bool = False) -> int: + if not is_secure and not self.listen_http: + raise ValueError("This server doesn't provide HTTP") + if is_secure and not self.listen_https: + raise ValueError("This server doesn't provide HTTPS") + port = self.https_port if is_secure else self.http_port + assert port is not None + return port + + def url(self, path: str, is_secure: bool = False) -> str: + port = self.port(is_secure) + scheme = "https" if is_secure else "http" + return f"{scheme}://{self.host}:{port}{path}" + + +def main_factory( + resource_class: type[resource.Resource], + *, + listen_http: bool = True, + listen_https: bool = True, +) -> Callable[[], None]: + if not listen_http and not listen_https: + raise ValueError("At least one of listen_http and listen_https must be set") + + def main() -> None: + from twisted.internet import reactor + + root = resource_class() + factory = Site(root) + + if listen_http: + http_port = reactor.listenTCP(0, factory) + + if listen_https: + parser = argparse.ArgumentParser() + parser.add_argument("--keyfile", help="SSL key file") + parser.add_argument("--certfile", help="SSL certificate file") + parser.add_argument( + "--cipher-string", + default=None, + help="SSL cipher string (optional)", + ) + args = parser.parse_args() + context_factory_kw = {} + if args.keyfile: + context_factory_kw["keyfile"] = args.keyfile + if args.certfile: + context_factory_kw["certfile"] = args.certfile + if args.cipher_string: + context_factory_kw["cipher_string"] = args.cipher_string + context_factory = ssl_context_factory(**context_factory_kw) + https_port = reactor.listenSSL(0, factory, context_factory) + + def print_listening(): + if listen_http: + http_host = http_port.getHost() + http_address = f"http://{http_host.host}:{http_host.port}" + print(http_address) + if listen_https: + https_host = https_port.getHost() + https_address = f"https://{https_host.host}:{https_host.port}" + print(https_address) + + reactor.callWhenRunning(print_listening) + reactor.run() + + return main diff --git a/tests/mockserver/http_resources.py b/tests/mockserver/http_resources.py new file mode 100644 index 000000000..d5687fc44 --- /dev/null +++ b/tests/mockserver/http_resources.py @@ -0,0 +1,349 @@ +from __future__ import annotations + +import gzip +import json +import random +from urllib.parse import urlencode + +from twisted.internet.task import deferLater +from twisted.web import resource, server +from twisted.web.server import NOT_DONE_YET +from twisted.web.util import Redirect, redirectTo + +from scrapy.utils.python import to_bytes, to_unicode + + +def getarg(request, name, default=None, type_=None): + if name in request.args: + value = request.args[name][0] + if type_ is not None: + value = type_(value) + return value + return default + + +def close_connection(request): + # We have to force a disconnection for HTTP/1.1 clients. Otherwise + # client keeps the connection open waiting for more data. + request.channel.loseConnection() + request.finish() + + +# most of the following resources are copied from twisted.web.test.test_webclient +class ForeverTakingResource(resource.Resource): + """ + L{ForeverTakingResource} is a resource which never finishes responding + to requests. + """ + + def __init__(self, write=False): + resource.Resource.__init__(self) + self._write = write + + def render(self, request): + if self._write: + request.write(b"some bytes") + return server.NOT_DONE_YET + + +class HostHeaderResource(resource.Resource): + """ + A testing resource which renders itself as the value of the host header + from the request. + """ + + def render(self, request): + return request.requestHeaders.getRawHeaders(b"host")[0] + + +class PayloadResource(resource.Resource): + """ + A testing resource which renders itself as the contents of the request body + as long as the request body is 100 bytes long, otherwise which renders + itself as C{"ERROR"}. + """ + + def render(self, request): + data = request.content.read() + contentLength = request.requestHeaders.getRawHeaders(b"content-length")[0] + if len(data) != 100 or int(contentLength) != 100: + return b"ERROR" + return data + + +class LeafResource(resource.Resource): + isLeaf = True + + def deferRequest(self, request, delay, f, *a, **kw): + from twisted.internet import reactor + + def _cancelrequest(_): + # silence CancelledError + d.addErrback(lambda _: None) + d.cancel() + + d = deferLater(reactor, delay, f, *a, **kw) + request.notifyFinish().addErrback(_cancelrequest) + return d + + +class Follow(LeafResource): + def render(self, request): + total = getarg(request, b"total", 100, type_=int) + show = getarg(request, b"show", 1, type_=int) + order = getarg(request, b"order", b"desc") + maxlatency = getarg(request, b"maxlatency", 0, type_=float) + n = getarg(request, b"n", total, type_=int) + if order == b"rand": + nlist = [random.randint(1, total) for _ in range(show)] + else: # order == "desc" + nlist = range(n, max(n - show, 0), -1) + + lag = random.random() * maxlatency + self.deferRequest(request, lag, self.renderRequest, request, nlist) + return NOT_DONE_YET + + def renderRequest(self, request, nlist): + s = """ """ + args = request.args.copy() + for nl in nlist: + args[b"n"] = [to_bytes(str(nl))] + argstr = urlencode(args, doseq=True) + s += f"follow {nl}
" + s += """""" + request.write(to_bytes(s)) + request.finish() + + +class Delay(LeafResource): + def render_GET(self, request): + n = getarg(request, b"n", 1, type_=float) + b = getarg(request, b"b", 1, type_=int) + if b: + # send headers now and delay body + request.write("") + self.deferRequest(request, n, self._delayedRender, request, n) + return NOT_DONE_YET + + def _delayedRender(self, request, n): + request.write(to_bytes(f"Response delayed for {n:.3f} seconds\n")) + request.finish() + + +class Status(LeafResource): + def render_GET(self, request): + n = getarg(request, b"n", 200, type_=int) + request.setResponseCode(n) + return b"" + + +class Raw(LeafResource): + def render_GET(self, request): + request.startedWriting = 1 + self.deferRequest(request, 0, self._delayedRender, request) + return NOT_DONE_YET + + render_POST = render_GET + + def _delayedRender(self, request): + raw = getarg(request, b"raw", b"HTTP 1.1 200 OK\n") + request.startedWriting = 1 + request.write(raw) + request.channel.transport.loseConnection() + request.finish() + + +class Echo(LeafResource): + def render_GET(self, request): + output = { + "headers": { + to_unicode(k): [to_unicode(v) for v in vs] + for k, vs in request.requestHeaders.getAllRawHeaders() + }, + "body": to_unicode(request.content.read()), + } + return to_bytes(json.dumps(output)) + + render_POST = render_GET + + +class RedirectTo(LeafResource): + def render(self, request): + goto = getarg(request, b"goto", b"/") + # we force the body content, otherwise Twisted redirectTo() + # returns HTML with bytes: + content = Redirect.render(self, request) + return content.replace( + b'http-equiv="refresh"', b'http-no-equiv="do-not-refresh-me"' + ) + + +class ContentLengthHeaderResource(resource.Resource): + """ + A testing resource which renders itself as the value of the Content-Length + header from the request. + """ + + def render(self, request): + return request.requestHeaders.getRawHeaders(b"content-length")[0] + + +class ChunkedResource(resource.Resource): + def render(self, request): + from twisted.internet import reactor + + def response(): + request.write(b"chunked ") + request.write(b"content\n") + request.finish() + + reactor.callLater(0, response) + return server.NOT_DONE_YET + + +class BrokenChunkedResource(resource.Resource): + def render(self, request): + from twisted.internet import reactor + + def response(): + request.write(b"chunked ") + request.write(b"content\n") + # Disable terminating chunk on finish. + request.chunked = False + close_connection(request) + + reactor.callLater(0, response) + return server.NOT_DONE_YET + + +class BrokenDownloadResource(resource.Resource): + def render(self, request): + from twisted.internet import reactor + + def response(): + request.setHeader(b"Content-Length", b"20") + request.write(b"partial") + close_connection(request) + + reactor.callLater(0, response) + return server.NOT_DONE_YET + + +class EmptyContentTypeHeaderResource(resource.Resource): + """ + A testing resource which renders itself as the value of request body + without content-type header in response. + """ + + def render(self, request): + request.setHeader("content-type", "") + return request.content.read() + + +class LargeChunkedFileResource(resource.Resource): + def render(self, request): + from twisted.internet import reactor + + def response(): + for i in range(1024): + request.write(b"x" * 1024) + request.finish() + + reactor.callLater(0, response) + return server.NOT_DONE_YET + + +class DuplicateHeaderResource(resource.Resource): + def render(self, request): + request.responseHeaders.setRawHeaders(b"Set-Cookie", [b"a=b", b"c=d"]) + return b"" + + +class UriResource(resource.Resource): + """Return the full uri that was requested""" + + def getChild(self, path, request): + return self + + def render(self, request): + # Note: this is an ugly hack for CONNECT request timeout test. + # Returning some data here fail SSL/TLS handshake + # ToDo: implement proper HTTPS proxy tests, not faking them. + if request.method != b"CONNECT": + return request.uri + return b"" + + +class ResponseHeadersResource(resource.Resource): + """Return a response with headers set from the JSON request body""" + + def render(self, request): + body = json.loads(request.content.read().decode()) + for header_name, header_value in body.items(): + request.responseHeaders.addRawHeader(header_name, header_value) + return json.dumps(body).encode("utf-8") + + +class Compress(resource.Resource): + """Compress the data sent in the request url params and set Content-Encoding header""" + + def render(self, request): + data = request.args.get(b"data")[0] + + accept_encoding_header = request.getHeader(b"accept-encoding") + + # include common encoding schemes here + if accept_encoding_header == b"gzip": + request.setHeader(b"Content-Encoding", b"gzip") + return gzip.compress(data) + + # just set this to trigger a test failure if no valid accept-encoding header was set + request.setResponseCode(500) + return b"Did not receive a valid accept-encoding header" + + +class SetCookie(resource.Resource): + """Return a response with a Set-Cookie header for each request url parameter""" + + def render(self, request): + for cookie_name, cookie_values in request.args.items(): + for cookie_value in cookie_values: + cookie = (cookie_name.decode() + "=" + cookie_value.decode()).encode() + request.setHeader(b"Set-Cookie", cookie) + return b"" diff --git a/tests/mockserver/proxy_echo.py b/tests/mockserver/proxy_echo.py new file mode 100644 index 000000000..6345726b6 --- /dev/null +++ b/tests/mockserver/proxy_echo.py @@ -0,0 +1,17 @@ +# This is only used by tests.test_downloader_handlers_http_base.TestHttpProxyBase + +from __future__ import annotations + +from .http_base import BaseMockServer, main_factory +from .http_resources import UriResource + + +class ProxyEchoMockServer(BaseMockServer): + module_name = "tests.mockserver.proxy_echo" + + +main = main_factory(UriResource) + + +if __name__ == "__main__": + main() diff --git a/tests/mockserver/simple_https.py b/tests/mockserver/simple_https.py new file mode 100644 index 000000000..5f23dd2c4 --- /dev/null +++ b/tests/mockserver/simple_https.py @@ -0,0 +1,46 @@ +# This is only used by tests.test_downloader_handlers_http_base.TestSimpleHttpsBase + +from __future__ import annotations + +from twisted.web import resource +from twisted.web.static import Data + +from .http_base import BaseMockServer, main_factory + + +class Root(resource.Resource): + def __init__(self): + resource.Resource.__init__(self) + self.putChild(b"file", Data(b"0123456789", "text/plain")) + + def getChild(self, name, request): + return self + + +class SimpleMockServer(BaseMockServer): + listen_http = False + module_name = "tests.mockserver.simple_https" + + def __init__(self, keyfile: str, certfile: str, cipher_string: str | None): + super().__init__() + self.keyfile = keyfile + self.certfile = certfile + self.cipher_string = cipher_string or "" + + def get_additional_args(self) -> list[str]: + args = [ + "--keyfile", + self.keyfile, + "--certfile", + self.certfile, + ] + if self.cipher_string is not None: + args.extend(["--cipher-string", self.cipher_string]) + return args + + +main = main_factory(Root, listen_http=False) + + +if __name__ == "__main__": + main() diff --git a/tests/mockserver/utils.py b/tests/mockserver/utils.py new file mode 100644 index 000000000..f43152f92 --- /dev/null +++ b/tests/mockserver/utils.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from pathlib import Path + +from OpenSSL import SSL +from twisted.internet import ssl + +from scrapy.utils.python import to_bytes + + +def ssl_context_factory( + keyfile="keys/localhost.key", certfile="keys/localhost.crt", cipher_string=None +): + factory = ssl.DefaultOpenSSLContextFactory( + str(Path(__file__).parent.parent / keyfile), + str(Path(__file__).parent.parent / certfile), + ) + if cipher_string: + ctx = factory.getContext() + # disabling TLS1.3 because it unconditionally enables some strong ciphers + ctx.set_options(SSL.OP_CIPHER_SERVER_PREFERENCE | SSL.OP_NO_TLSv1_3) + ctx.set_cipher_list(to_bytes(cipher_string)) + return factory diff --git a/tests/pipelines.py b/tests/pipelines.py index d9fc12676..4f00d6919 100644 --- a/tests/pipelines.py +++ b/tests/pipelines.py @@ -4,13 +4,13 @@ Some pipelines used for testing class ZeroDivisionErrorPipeline: - def open_spider(self, spider): + def open_spider(self): 1 / 0 - def process_item(self, item, spider): + def process_item(self, item): return item class ProcessWithZeroDivisionErrorPipeline: - def process_item(self, item, spider): + def process_item(self, item): 1 / 0 diff --git a/tests/spiders.py b/tests/spiders.py index c47f2bd2b..43cdcaef3 100644 --- a/tests/spiders.py +++ b/tests/spiders.py @@ -418,7 +418,7 @@ class CrawlSpiderWithParseMethod(MockServerSpider, CrawlSpider): async def start(self): test_body = b""" - Page title<title></head> + <head><title>Page title

Item 200

Item 201

@@ -472,7 +472,7 @@ class CrawlSpiderWithErrback(CrawlSpiderWithParseMethod): async def start(self): test_body = b""" - Page title<title></head> + <head><title>Page title

Item 200

Item 201

diff --git a/tests/test_addons.py b/tests/test_addons.py index b4294c815..457945ea5 100644 --- a/tests/test_addons.py +++ b/tests/test_addons.py @@ -3,7 +3,6 @@ from typing import Any from unittest.mock import patch from twisted.internet.defer import inlineCallbacks -from twisted.trial import unittest from scrapy import Spider from scrapy.crawler import Crawler, CrawlerRunner @@ -52,7 +51,7 @@ class TestAddon: assert settings["KEY3"] == "addon" -class TestAddonManager(unittest.TestCase): +class TestAddonManager: def test_load_settings(self): settings_dict = { "ADDONS": {"tests.test_addons.SimpleAddon": 0}, @@ -150,7 +149,7 @@ class TestAddonManager(unittest.TestCase): ) assert ( crawler.settings.get(FALLBACK_SETTING) - == "scrapy.core.downloader.handlers.http.HTTPDownloadHandler" + == "scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler" ) settings_dict = { diff --git a/tests/test_closespider.py b/tests/test_closespider.py index c6ec690a1..2243cd371 100644 --- a/tests/test_closespider.py +++ b/tests/test_closespider.py @@ -1,8 +1,7 @@ from twisted.internet.defer import inlineCallbacks -from twisted.trial.unittest import TestCase from scrapy.utils.test import get_crawler -from tests.mockserver import MockServer +from tests.mockserver.http import MockServer from tests.spiders import ( ErrorSpider, FollowAllSpider, @@ -12,14 +11,14 @@ from tests.spiders import ( ) -class TestCloseSpider(TestCase): +class TestCloseSpider: @classmethod - def setUpClass(cls): + def setup_class(cls): cls.mockserver = MockServer() cls.mockserver.__enter__() @classmethod - def tearDownClass(cls): + def teardown_class(cls): cls.mockserver.__exit__(None, None, None) @inlineCallbacks diff --git a/tests/test_cmdline_crawl_with_pipeline/__init__.py b/tests/test_cmdline_crawl_with_pipeline/__init__.py index c6fdb13ea..2ad8fb8a4 100644 --- a/tests/test_cmdline_crawl_with_pipeline/__init__.py +++ b/tests/test_cmdline_crawl_with_pipeline/__init__.py @@ -14,7 +14,7 @@ class TestCmdlineCrawlPipeline: return proc.returncode, stderr def test_open_spider_normally_in_pipeline(self): - returncode, stderr = self._execute("normal") + returncode, _ = self._execute("normal") assert returncode == 0 def test_exception_at_open_spider_in_pipeline(self): diff --git a/tests/test_cmdline_crawl_with_pipeline/test_spider/pipelines.py b/tests/test_cmdline_crawl_with_pipeline/test_spider/pipelines.py index 3e29c70ed..577ecd686 100644 --- a/tests/test_cmdline_crawl_with_pipeline/test_spider/pipelines.py +++ b/tests/test_cmdline_crawl_with_pipeline/test_spider/pipelines.py @@ -2,7 +2,7 @@ class TestSpiderPipeline: def open_spider(self, spider): pass - def process_item(self, item, spider): + def process_item(self, item): return item @@ -10,5 +10,5 @@ class TestSpiderExceptionPipeline: def open_spider(self, spider): raise RuntimeError("exception") - def process_item(self, item, spider): + def process_item(self, item): return item diff --git a/tests/test_command_check.py b/tests/test_command_check.py index 97bd9d726..dc07ed695 100644 --- a/tests/test_command_check.py +++ b/tests/test_command_check.py @@ -1,19 +1,29 @@ +from __future__ import annotations + import sys from io import StringIO +from typing import TYPE_CHECKING +from unittest import TestCase from unittest.mock import Mock, PropertyMock, call, patch from scrapy.commands.check import Command, TextTestResult -from tests.test_commands import TestCommandBase +from tests.test_commands import TestProjectBase +from tests.utils.cmdline import proc + +if TYPE_CHECKING: + from pathlib import Path -class TestCheckCommand(TestCommandBase): - def setup_method(self): - super().setup_method() - self.spider_name = "check_spider" - self.spider = (self.proj_mod_path / "spiders" / "checkspider.py").resolve() +class DummyTestCase(TestCase): + pass - def _write_contract(self, contracts, parse_def): - self.spider.write_text( + +class TestCheckCommand(TestProjectBase): + spider_name = "check_spider" + + def _write_contract(self, proj_path: Path, contracts: str, parse_def: str) -> None: + spider = proj_path / self.project_name / "spiders" / "checkspider.py" + spider.write_text( f""" import scrapy @@ -21,6 +31,10 @@ class CheckSpider(scrapy.Spider): name = '{self.spider_name}' start_urls = ['data:,'] + custom_settings = {{ + "DOWNLOAD_DELAY": 0, + }} + def parse(self, response, **cb_kwargs): \"\"\" @url data:, @@ -31,32 +45,34 @@ class CheckSpider(scrapy.Spider): encoding="utf-8", ) - def _test_contract(self, contracts="", parse_def="pass"): - self._write_contract(contracts, parse_def) - p, out, err = self.proc("check") + def _test_contract( + self, proj_path: Path, contracts: str = "", parse_def: str = "pass" + ) -> None: + self._write_contract(proj_path, contracts, parse_def) + ret, out, err = proc("check", cwd=proj_path) assert "F" not in out assert "OK" in err - assert p.returncode == 0 + assert ret == 0 - def test_check_returns_requests_contract(self): + def test_check_returns_requests_contract(self, proj_path: Path) -> None: contracts = """ @returns requests 1 """ parse_def = """ yield scrapy.Request(url='http://next-url.com') """ - self._test_contract(contracts, parse_def) + self._test_contract(proj_path, contracts, parse_def) - def test_check_returns_items_contract(self): + def test_check_returns_items_contract(self, proj_path: Path) -> None: contracts = """ @returns items 1 """ parse_def = """ yield {'key1': 'val1', 'key2': 'val2'} """ - self._test_contract(contracts, parse_def) + self._test_contract(proj_path, contracts, parse_def) - def test_check_cb_kwargs_contract(self): + def test_check_cb_kwargs_contract(self, proj_path: Path) -> None: contracts = """ @cb_kwargs {"arg1": "val1", "arg2": "val2"} """ @@ -64,18 +80,18 @@ class CheckSpider(scrapy.Spider): if len(cb_kwargs.items()) == 0: raise Exception("Callback args not set") """ - self._test_contract(contracts, parse_def) + self._test_contract(proj_path, contracts, parse_def) - def test_check_scrapes_contract(self): + def test_check_scrapes_contract(self, proj_path: Path) -> None: contracts = """ @scrapes key1 key2 """ parse_def = """ yield {'key1': 'val1', 'key2': 'val2'} """ - self._test_contract(contracts, parse_def) + self._test_contract(proj_path, contracts, parse_def) - def test_check_all_default_contracts(self): + def test_check_all_default_contracts(self, proj_path: Path) -> None: contracts = """ @returns items 1 @returns requests 1 @@ -88,67 +104,69 @@ class CheckSpider(scrapy.Spider): if len(cb_kwargs.items()) == 0: raise Exception("Callback args not set") """ - self._test_contract(contracts, parse_def) + self._test_contract(proj_path, contracts, parse_def) - def test_SCRAPY_CHECK_set(self): + def test_SCRAPY_CHECK_set(self, proj_path: Path) -> None: parse_def = """ import os if not os.environ.get('SCRAPY_CHECK'): raise Exception('SCRAPY_CHECK not set') """ - self._test_contract(parse_def=parse_def) + self._test_contract(proj_path, parse_def=parse_def) def test_printSummary_with_unsuccessful_test_result_without_errors_and_without_failures( self, - ): + ) -> None: result = TextTestResult(Mock(), descriptions=False, verbosity=1) start_time = 1.0 stop_time = 2.0 result.testsRun = 5 result.failures = [] result.errors = [] - result.unexpectedSuccesses = ["a", "b"] + result.unexpectedSuccesses = [DummyTestCase(), DummyTestCase()] with patch.object(result.stream, "write") as mock_write: result.printSummary(start_time, stop_time) mock_write.assert_has_calls([call("FAILED"), call("\n")]) - def test_printSummary_with_unsuccessful_test_result_with_only_failures(self): + def test_printSummary_with_unsuccessful_test_result_with_only_failures( + self, + ) -> None: result = TextTestResult(Mock(), descriptions=False, verbosity=1) start_time = 1.0 stop_time = 2.0 result.testsRun = 5 - result.failures = [(self, "failure")] + result.failures = [(DummyTestCase(), "failure")] result.errors = [] with patch.object(result.stream, "writeln") as mock_write: result.printSummary(start_time, stop_time) mock_write.assert_called_with(" (failures=1)") - def test_printSummary_with_unsuccessful_test_result_with_only_errors(self): + def test_printSummary_with_unsuccessful_test_result_with_only_errors(self) -> None: result = TextTestResult(Mock(), descriptions=False, verbosity=1) start_time = 1.0 stop_time = 2.0 result.testsRun = 5 result.failures = [] - result.errors = [(self, "error")] + result.errors = [(DummyTestCase(), "error")] with patch.object(result.stream, "writeln") as mock_write: result.printSummary(start_time, stop_time) mock_write.assert_called_with(" (errors=1)") def test_printSummary_with_unsuccessful_test_result_with_both_failures_and_errors( self, - ): + ) -> None: result = TextTestResult(Mock(), descriptions=False, verbosity=1) start_time = 1.0 stop_time = 2.0 result.testsRun = 5 - result.failures = [(self, "failure")] - result.errors = [(self, "error")] + result.failures = [(DummyTestCase(), "failure")] + result.errors = [(DummyTestCase(), "error")] with patch.object(result.stream, "writeln") as mock_write: result.printSummary(start_time, stop_time) mock_write.assert_called_with(" (failures=1, errors=1)") @patch("scrapy.commands.check.ContractsManager") - def test_run_with_opts_list_prints_spider(self, cm_cls_mock): + def test_run_with_opts_list_prints_spider(self, cm_cls_mock) -> None: output = StringIO() sys.stdout = output cmd = Command() @@ -175,7 +193,7 @@ class CheckSpider(scrapy.Spider): @patch("scrapy.commands.check.ContractsManager") def test_run_without_opts_list_does_not_crawl_spider_with_no_tested_methods( self, cm_cls_mock - ): + ) -> None: cmd = Command() cmd.settings = Mock(getwithbase=Mock(return_value={})) cm_cls_mock.return_value = cm_mock = Mock() @@ -186,7 +204,7 @@ class CheckSpider(scrapy.Spider): spider_loader_mock.load.side_effect = lambda x: {spider_name: spider_cls_mock}[ x ] - tested_methods = [] + tested_methods: list[str] = [] cm_mock.tested_methods_from_spidercls.side_effect = lambda x: { spider_cls_mock: tested_methods }[x] diff --git a/tests/test_command_crawl.py b/tests/test_command_crawl.py index 0ab0659b2..5a223d122 100644 --- a/tests/test_command_crawl.py +++ b/tests/test_command_crawl.py @@ -1,22 +1,29 @@ from __future__ import annotations -from pathlib import Path +from typing import TYPE_CHECKING -from tests.test_commands import TestCommandBase +from tests.test_commands import TestProjectBase +from tests.utils.cmdline import proc + +if TYPE_CHECKING: + from collections.abc import Iterable + from pathlib import Path -class TestCrawlCommand(TestCommandBase): - def crawl(self, code, args=()): - Path(self.proj_mod_path, "spiders", "myspider.py").write_text( +class TestCrawlCommand(TestProjectBase): + def crawl( + self, code: str, proj_path: Path, args: Iterable[str] = () + ) -> tuple[int, str, str]: + (proj_path / self.project_name / "spiders" / "myspider.py").write_text( code, encoding="utf-8" ) - return self.proc("crawl", "myspider", *args) + return proc("crawl", "myspider", *args, cwd=proj_path) - def get_log(self, code, args=()): - _, _, stderr = self.crawl(code, args=args) + def get_log(self, code: str, proj_path: Path, args: Iterable[str] = ()) -> str: + _, _, stderr = self.crawl(code, proj_path, args=args) return stderr - def test_no_output(self): + def test_no_output(self, proj_path: Path) -> None: spider_code = """ import scrapy @@ -28,7 +35,7 @@ class MySpider(scrapy.Spider): return yield """ - log = self.get_log(spider_code) + log = self.get_log(spider_code, proj_path) assert "[myspider] DEBUG: It works!" in log assert ( "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" @@ -36,7 +43,7 @@ class MySpider(scrapy.Spider): ) assert "Spider closed (finished)" in log - def test_output(self): + def test_output(self, proj_path: Path) -> None: spider_code = """ import scrapy @@ -49,10 +56,10 @@ class MySpider(scrapy.Spider): yield """ args = ["-o", "example.json"] - log = self.get_log(spider_code, args=args) + log = self.get_log(spider_code, proj_path, args=args) assert "[myspider] DEBUG: FEEDS: {'example.json': {'format': 'json'}}" in log - def test_overwrite_output(self): + def test_overwrite_output(self, proj_path: Path) -> None: spider_code = """ import json import scrapy @@ -69,18 +76,19 @@ class MySpider(scrapy.Spider): return yield """ - Path(self.cwd, "example.json").write_text("not empty", encoding="utf-8") + j = proj_path / "example.json" + j.write_text("not empty", encoding="utf-8") args = ["-O", "example.json"] - log = self.get_log(spider_code, args=args) + log = self.get_log(spider_code, proj_path, args=args) assert ( '[myspider] DEBUG: FEEDS: {"example.json": {"format": "json", "overwrite": true}}' in log ) - with Path(self.cwd, "example.json").open(encoding="utf-8") as f2: + with j.open(encoding="utf-8") as f2: first_line = f2.readline() assert first_line != "not empty" - def test_output_and_overwrite_output(self): + def test_output_and_overwrite_output(self, proj_path: Path) -> None: spider_code = """ import scrapy @@ -92,12 +100,12 @@ class MySpider(scrapy.Spider): yield """ args = ["-o", "example1.json", "-O", "example2.json"] - log = self.get_log(spider_code, args=args) + log = self.get_log(spider_code, proj_path, args=args) assert ( "error: Please use only one of -o/--output and -O/--overwrite-output" in log ) - def test_default_reactor(self): + def test_default_reactor(self, proj_path: Path) -> None: spider_code = """ import scrapy @@ -109,7 +117,7 @@ class MySpider(scrapy.Spider): return yield """ - log = self.get_log(spider_code, args=("-s", "TWISTED_REACTOR=")) + log = self.get_log(spider_code, proj_path, args=("-s", "TWISTED_REACTOR=")) assert "[myspider] DEBUG: It works!" in log assert ( "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" diff --git a/tests/test_command_fetch.py b/tests/test_command_fetch.py index c83594361..4f4f19c3c 100644 --- a/tests/test_command_fetch.py +++ b/tests/test_command_fetch.py @@ -1,35 +1,32 @@ -from tests.mockserver import MockServer -from tests.test_commands import TestProjectBase +from __future__ import annotations + +from typing import TYPE_CHECKING + +from tests.utils.cmdline import proc + +if TYPE_CHECKING: + from tests.mockserver.http import MockServer -class TestFetchCommand(TestProjectBase): - @classmethod - def setup_class(cls): - cls.mockserver = MockServer() - cls.mockserver.__enter__() - - @classmethod - def teardown_class(cls): - cls.mockserver.__exit__(None, None, None) - - def test_output(self): - _, out, _ = self.proc("fetch", self.mockserver.url("/text")) +class TestFetchCommand: + def test_output(self, mockserver: MockServer) -> None: + _, out, _ = proc("fetch", mockserver.url("/text")) assert out.strip() == "Works" - def test_redirect_default(self): - _, out, _ = self.proc("fetch", self.mockserver.url("/redirect")) + def test_redirect_default(self, mockserver: MockServer) -> None: + _, out, _ = proc("fetch", mockserver.url("/redirect")) assert out.strip() == "Redirected here" - def test_redirect_disabled(self): - _, _, err = self.proc( - "fetch", "--no-redirect", self.mockserver.url("/redirect-no-meta-refresh") + def test_redirect_disabled(self, mockserver: MockServer) -> None: + _, _, err = proc( + "fetch", "--no-redirect", mockserver.url("/redirect-no-meta-refresh") ) err = err.strip() - assert "downloader/response_status_count/302" in err, err - assert "downloader/response_status_count/200" not in err, err + assert "downloader/response_status_count/302" in err + assert "downloader/response_status_count/200" not in err - def test_headers(self): - _, out, _ = self.proc("fetch", self.mockserver.url("/text"), "--headers") + def test_headers(self, mockserver: MockServer) -> None: + _, out, _ = proc("fetch", mockserver.url("/text"), "--headers") out = out.replace("\r", "") # required on win32 - assert "Server: TwistedWeb" in out, out + assert "Server: TwistedWeb" in out assert "Content-Type: text/plain" in out diff --git a/tests/test_command_genspider.py b/tests/test_command_genspider.py index 18ec81fed..fd9505060 100644 --- a/tests/test_command_genspider.py +++ b/tests/test_command_genspider.py @@ -1,66 +1,81 @@ from __future__ import annotations -import os +import re from pathlib import Path -from tests.test_commands import TestCommandBase, TestProjectBase +import pytest + +from tests.test_commands import TestProjectBase +from tests.utils.cmdline import call, proc -class TestGenspiderCommand(TestCommandBase): - def test_arguments(self): +def find_in_file(filename: Path, regex: str) -> re.Match | None: + """Find first pattern occurrence in file""" + pattern = re.compile(regex) + with filename.open("r", encoding="utf-8") as f: + for line in f: + match = pattern.search(line) + if match is not None: + return match + return None + + +class TestGenspiderCommand(TestProjectBase): + def test_arguments(self, proj_path: Path) -> None: + spider = proj_path / self.project_name / "spiders" / "test_name.py" # only pass one argument. spider script shouldn't be created - assert self.call("genspider", "test_name") == 2 - assert not Path(self.proj_mod_path, "spiders", "test_name.py").exists() + assert call("genspider", "test_name", cwd=proj_path) == 2 + assert not spider.exists() # pass two arguments . spider script should be created - assert self.call("genspider", "test_name", "test.com") == 0 - assert Path(self.proj_mod_path, "spiders", "test_name.py").exists() + assert call("genspider", "test_name", "test.com", cwd=proj_path) == 0 + assert spider.exists() - def test_template(self, tplname="crawl"): + @pytest.mark.parametrize( + "tplname", + [ + "basic", + "crawl", + "xmlfeed", + "csvfeed", + ], + ) + def test_template(self, tplname: str, proj_path: Path) -> None: args = [f"--template={tplname}"] if tplname else [] spname = "test_spider" spmodule = f"{self.project_name}.spiders.{spname}" - p, out, err = self.proc("genspider", spname, "test.com", *args) + spfile = proj_path / self.project_name / "spiders" / f"{spname}.py" + _, out, _ = proc("genspider", spname, "test.com", *args, cwd=proj_path) assert ( - f"Created spider {spname!r} using template {tplname!r} in module:{os.linesep} {spmodule}" + f"Created spider {spname!r} using template {tplname!r} in module:\n {spmodule}" in out ) - assert Path(self.proj_mod_path, "spiders", "test_spider.py").exists() - modify_time_before = ( - Path(self.proj_mod_path, "spiders", "test_spider.py").stat().st_mtime - ) - p, out, err = self.proc("genspider", spname, "test.com", *args) + assert spfile.exists() + modify_time_before = spfile.stat().st_mtime + _, out, _ = proc("genspider", spname, "test.com", *args, cwd=proj_path) assert f"Spider {spname!r} already exists in module" in out - modify_time_after = ( - Path(self.proj_mod_path, "spiders", "test_spider.py").stat().st_mtime - ) + modify_time_after = spfile.stat().st_mtime assert modify_time_after == modify_time_before - def test_template_basic(self): - self.test_template("basic") + def test_list(self, proj_path: Path) -> None: + assert call("genspider", "--list", cwd=proj_path) == 0 - def test_template_csvfeed(self): - self.test_template("csvfeed") + def test_dump(self, proj_path: Path) -> None: + assert call("genspider", "--dump=basic", cwd=proj_path) == 0 + assert call("genspider", "-d", "basic", cwd=proj_path) == 0 - def test_template_xmlfeed(self): - self.test_template("xmlfeed") - - def test_list(self): - assert self.call("genspider", "--list") == 0 - - def test_dump(self): - assert self.call("genspider", "--dump=basic") == 0 - assert self.call("genspider", "-d", "basic") == 0 - - def test_same_name_as_project(self): - assert self.call("genspider", self.project_name) == 2 - assert not Path( - self.proj_mod_path, "spiders", f"{self.project_name}.py" + def test_same_name_as_project(self, proj_path: Path) -> None: + assert call("genspider", self.project_name, cwd=proj_path) == 2 + assert not ( + proj_path / self.project_name / "spiders" / f"{self.project_name}.py" ).exists() - def test_same_filename_as_existing_spider(self, force=False): + @pytest.mark.parametrize("force", [True, False]) + def test_same_filename_as_existing_spider( + self, force: bool, proj_path: Path + ) -> None: file_name = "example" - file_path = Path(self.proj_mod_path, "spiders", f"{file_name}.py") - assert self.call("genspider", file_name, "example.com") == 0 + file_path = proj_path / self.project_name / "spiders" / f"{file_name}.py" + assert call("genspider", file_name, "example.com", cwd=proj_path) == 0 assert file_path.exists() # change name of spider but not its file name @@ -74,7 +89,9 @@ class TestGenspiderCommand(TestCommandBase): file_contents_before = file_data if force: - p, out, err = self.proc("genspider", "--force", file_name, "example.com") + _, out, _ = proc( + "genspider", "--force", file_name, "example.com", cwd=proj_path + ) assert ( f"Created spider {file_name!r} using template 'basic' in module" in out ) @@ -83,101 +100,79 @@ class TestGenspiderCommand(TestCommandBase): file_contents_after = file_path.read_text(encoding="utf-8") assert file_contents_after != file_contents_before else: - p, out, err = self.proc("genspider", file_name, "example.com") + _, out, _ = proc("genspider", file_name, "example.com", cwd=proj_path) assert f"{file_path.resolve()} already exists" in out modify_time_after = file_path.stat().st_mtime assert modify_time_after == modify_time_before file_contents_after = file_path.read_text(encoding="utf-8") assert file_contents_after == file_contents_before - def test_same_filename_as_existing_spider_force(self): - self.test_same_filename_as_existing_spider(force=True) - - def test_url(self, url="test.com", domain="test.com"): - assert self.call("genspider", "--force", "test_name", url) == 0 - assert ( - self.find_in_file( - Path(self.proj_mod_path, "spiders", "test_name.py"), - r"allowed_domains\s*=\s*\[['\"](.+)['\"]\]", - ).group(1) - == domain - ) - assert ( - self.find_in_file( - Path(self.proj_mod_path, "spiders", "test_name.py"), - r"start_urls\s*=\s*\[['\"](.+)['\"]\]", - ).group(1) - == f"https://{domain}" - ) - - def test_url_schema(self): - self.test_url("https://test.com", "test.com") + @pytest.mark.parametrize( + ("url", "domain"), + [ + ("test.com", "test.com"), + ("https://test.com", "test.com"), + ], + ) + def test_url(self, url: str, domain: str, proj_path: Path) -> None: + assert call("genspider", "--force", "test_name", url, cwd=proj_path) == 0 + spider = proj_path / self.project_name / "spiders" / "test_name.py" + m = find_in_file(spider, r"allowed_domains\s*=\s*\[['\"](.+)['\"]\]") + assert m is not None + assert m.group(1) == domain + m = find_in_file(spider, r"start_urls\s*=\s*\[['\"](.+)['\"]\]") + assert m is not None + assert m.group(1) == f"https://{domain}" + @pytest.mark.parametrize( + ("url", "expected", "template"), + [ + # basic + ("https://test.com", "https://test.com", "basic"), + ("http://test.com", "http://test.com", "basic"), + ("http://test.com/other/path", "http://test.com/other/path", "basic"), + ("test.com/other/path", "https://test.com/other/path", "basic"), + # crawl + ("https://test.com", "https://test.com", "crawl"), + ("http://test.com", "http://test.com", "crawl"), + ("http://test.com/other/path", "http://test.com/other/path", "crawl"), + ("test.com/other/path", "https://test.com/other/path", "crawl"), + ("test.com", "https://test.com", "crawl"), + # xmlfeed + ("https://test.com/feed.xml", "https://test.com/feed.xml", "xmlfeed"), + ("http://test.com/feed.xml", "http://test.com/feed.xml", "xmlfeed"), + ("test.com/feed.xml", "https://test.com/feed.xml", "xmlfeed"), + # csvfeed + ("https://test.com/feed.csv", "https://test.com/feed.csv", "csvfeed"), + ("http://test.com/feed.xml", "http://test.com/feed.xml", "csvfeed"), + ("test.com/feed.csv", "https://test.com/feed.csv", "csvfeed"), + ], + ) def test_template_start_urls( - self, url="test.com", expected="https://test.com", template="basic" - ): - assert self.call("genspider", "-t", template, "--force", "test_name", url) == 0 + self, url: str, expected: str, template: str, proj_path: Path + ) -> None: assert ( - self.find_in_file( - Path(self.proj_mod_path, "spiders", "test_name.py"), - r"start_urls\s*=\s*\[['\"](.+)['\"]\]", - ).group(1) - == expected - ) - - def test_genspider_basic_start_urls(self): - self.test_template_start_urls("https://test.com", "https://test.com", "basic") - self.test_template_start_urls("http://test.com", "http://test.com", "basic") - self.test_template_start_urls( - "http://test.com/other/path", "http://test.com/other/path", "basic" - ) - self.test_template_start_urls( - "test.com/other/path", "https://test.com/other/path", "basic" - ) - - def test_genspider_crawl_start_urls(self): - self.test_template_start_urls("https://test.com", "https://test.com", "crawl") - self.test_template_start_urls("http://test.com", "http://test.com", "crawl") - self.test_template_start_urls( - "http://test.com/other/path", "http://test.com/other/path", "crawl" - ) - self.test_template_start_urls( - "test.com/other/path", "https://test.com/other/path", "crawl" - ) - self.test_template_start_urls("test.com", "https://test.com", "crawl") - - def test_genspider_xmlfeed_start_urls(self): - self.test_template_start_urls( - "https://test.com/feed.xml", "https://test.com/feed.xml", "xmlfeed" - ) - self.test_template_start_urls( - "http://test.com/feed.xml", "http://test.com/feed.xml", "xmlfeed" - ) - self.test_template_start_urls( - "test.com/feed.xml", "https://test.com/feed.xml", "xmlfeed" - ) - - def test_genspider_csvfeed_start_urls(self): - self.test_template_start_urls( - "https://test.com/feed.csv", "https://test.com/feed.csv", "csvfeed" - ) - self.test_template_start_urls( - "http://test.com/feed.xml", "http://test.com/feed.xml", "csvfeed" - ) - self.test_template_start_urls( - "test.com/feed.csv", "https://test.com/feed.csv", "csvfeed" + call( + "genspider", "-t", template, "--force", "test_name", url, cwd=proj_path + ) + == 0 ) + spider = proj_path / self.project_name / "spiders" / "test_name.py" + m = find_in_file(spider, r"start_urls\s*=\s*\[['\"](.+)['\"]\]") + assert m is not None + assert m.group(1) == expected -class TestGenspiderStandaloneCommand(TestProjectBase): - def test_generate_standalone_spider(self): - self.call("genspider", "example", "example.com") - assert Path(self.temp_path, "example.py").exists() +class TestGenspiderStandaloneCommand: + def test_generate_standalone_spider(self, tmp_path: Path) -> None: + call("genspider", "example", "example.com", cwd=tmp_path) + assert Path(tmp_path, "example.py").exists() - def test_same_name_as_existing_file(self, force=False): + @pytest.mark.parametrize("force", [True, False]) + def test_same_name_as_existing_file(self, force: bool, tmp_path: Path) -> None: file_name = "example" - file_path = Path(self.temp_path, file_name + ".py") - p, out, err = self.proc("genspider", file_name, "example.com") + file_path = Path(tmp_path, file_name + ".py") + _, out, _ = proc("genspider", file_name, "example.com", cwd=tmp_path) assert f"Created spider {file_name!r} using template 'basic' " in out assert file_path.exists() modify_time_before = file_path.stat().st_mtime @@ -185,8 +180,14 @@ class TestGenspiderStandaloneCommand(TestProjectBase): if force: # use different template to ensure contents were changed - p, out, err = self.proc( - "genspider", "--force", "-t", "crawl", file_name, "example.com" + _, out, _ = proc( + "genspider", + "--force", + "-t", + "crawl", + file_name, + "example.com", + cwd=tmp_path, ) assert f"Created spider {file_name!r} using template 'crawl' " in out modify_time_after = file_path.stat().st_mtime @@ -194,15 +195,11 @@ class TestGenspiderStandaloneCommand(TestProjectBase): file_contents_after = file_path.read_text(encoding="utf-8") assert file_contents_after != file_contents_before else: - p, out, err = self.proc("genspider", file_name, "example.com") + _, out, _ = proc("genspider", file_name, "example.com", cwd=tmp_path) assert ( - f"{Path(self.temp_path, file_name + '.py').resolve()} already exists" - in out + f"{Path(tmp_path, file_name + '.py').resolve()} already exists" in out ) modify_time_after = file_path.stat().st_mtime assert modify_time_after == modify_time_before file_contents_after = file_path.read_text(encoding="utf-8") assert file_contents_after == file_contents_before - - def test_same_name_as_existing_file_force(self): - self.test_same_name_as_existing_file(force=True) diff --git a/tests/test_command_parse.py b/tests/test_command_parse.py index 5c3120c21..85529e607 100644 --- a/tests/test_command_parse.py +++ b/tests/test_command_parse.py @@ -1,27 +1,29 @@ +from __future__ import annotations + import argparse import re -from pathlib import Path +from typing import TYPE_CHECKING + +import pytest from scrapy.commands import parse from scrapy.settings import Settings -from tests.mockserver import MockServer -from tests.test_commands import TestCommandBase +from tests.test_commands import TestProjectBase +from tests.utils.cmdline import call, proc + +if TYPE_CHECKING: + from pathlib import Path + + from tests.mockserver.http import MockServer -class TestParseCommand(TestCommandBase): - @classmethod - def setup_class(cls): - cls.mockserver = MockServer() - cls.mockserver.__enter__() +class TestParseCommand(TestProjectBase): + spider_name = "parse_spider" - @classmethod - def teardown_class(cls): - cls.mockserver.__exit__(None, None, None) - - def setup_method(self): - super().setup_method() - self.spider_name = "parse_spider" - (self.proj_mod_path / "spiders" / "myspider.py").write_text( + @pytest.fixture(autouse=True) + def create_files(self, proj_path: Path) -> None: + proj_mod_path = proj_path / self.project_name + (proj_mod_path / "spiders" / "myspider.py").write_text( f""" import scrapy from scrapy.linkextractors import LinkExtractor @@ -30,7 +32,13 @@ from scrapy.utils.test import get_from_asyncio_queue import asyncio -class AsyncDefAsyncioReturnSpider(scrapy.Spider): +class BaseSpider(scrapy.Spider): + custom_settings = {{ + "DOWNLOAD_DELAY": 0, + }} + + +class AsyncDefAsyncioReturnSpider(BaseSpider): name = "asyncdef_asyncio_return" async def parse(self, response): @@ -39,7 +47,7 @@ class AsyncDefAsyncioReturnSpider(scrapy.Spider): self.logger.info(f"Got response {{status}}") return [{{'id': 1}}, {{'id': 2}}] -class AsyncDefAsyncioReturnSingleElementSpider(scrapy.Spider): +class AsyncDefAsyncioReturnSingleElementSpider(BaseSpider): name = "asyncdef_asyncio_return_single_element" async def parse(self, response): @@ -48,7 +56,7 @@ class AsyncDefAsyncioReturnSingleElementSpider(scrapy.Spider): self.logger.info(f"Got response {{status}}") return {{'foo': 42}} -class AsyncDefAsyncioGenLoopSpider(scrapy.Spider): +class AsyncDefAsyncioGenLoopSpider(BaseSpider): name = "asyncdef_asyncio_gen_loop" async def parse(self, response): @@ -57,7 +65,7 @@ class AsyncDefAsyncioGenLoopSpider(scrapy.Spider): yield {{'foo': i}} self.logger.info(f"Got response {{response.status}}") -class AsyncDefAsyncioSpider(scrapy.Spider): +class AsyncDefAsyncioSpider(BaseSpider): name = "asyncdef_asyncio" async def parse(self, response): @@ -65,7 +73,7 @@ class AsyncDefAsyncioSpider(scrapy.Spider): status = await get_from_asyncio_queue(response.status) self.logger.debug(f"Got response {{status}}") -class AsyncDefAsyncioGenExcSpider(scrapy.Spider): +class AsyncDefAsyncioGenExcSpider(BaseSpider): name = "asyncdef_asyncio_gen_exc" async def parse(self, response): @@ -87,7 +95,8 @@ class MySpider(scrapy.Spider): custom_settings = {{ "DOWNLOADER_MIDDLEWARES": {{ CallbackSignatureDownloaderMiddleware: 0, - }} + }}, + "DOWNLOAD_DELAY": 0, }} def parse(self, response): @@ -120,6 +129,10 @@ class MySpider(scrapy.Spider): class MyGoodCrawlSpider(CrawlSpider): name = 'goodcrawl{self.spider_name}' + custom_settings = {{ + "DOWNLOAD_DELAY": 0, + }} + rules = ( Rule(LinkExtractor(allow=r'/html'), callback='parse_item', follow=True), Rule(LinkExtractor(allow=r'/text'), follow=True), @@ -136,6 +149,10 @@ class MyBadCrawlSpider(CrawlSpider): '''Spider which doesn't define a parse_item callback while using it in a rule.''' name = 'badcrawl{self.spider_name}' + custom_settings = {{ + "DOWNLOAD_DELAY": 0, + }} + rules = ( Rule(LinkExtractor(allow=r'/html'), callback='parse_item', follow=True), ) @@ -146,29 +163,29 @@ class MyBadCrawlSpider(CrawlSpider): encoding="utf-8", ) - (self.proj_mod_path / "pipelines.py").write_text( + (proj_mod_path / "pipelines.py").write_text( """ import logging class MyPipeline: component_name = 'my_pipeline' - def process_item(self, item, spider): + def process_item(self, item): logging.info('It Works!') return item """, encoding="utf-8", ) - with (self.proj_mod_path / "settings.py").open("a", encoding="utf-8") as f: + with (proj_mod_path / "settings.py").open("a", encoding="utf-8") as f: f.write( f""" ITEM_PIPELINES = {{'{self.project_name}.pipelines.MyPipeline': 1}} """ ) - def test_spider_arguments(self): - _, _, stderr = self.proc( + def test_spider_arguments(self, proj_path: Path, mockserver: MockServer) -> None: + _, _, stderr = proc( "parse", "--spider", self.spider_name, @@ -177,13 +194,14 @@ ITEM_PIPELINES = {{'{self.project_name}.pipelines.MyPipeline': 1}} "-c", "parse", "--verbose", - self.mockserver.url("/html"), + mockserver.url("/html"), + cwd=proj_path, ) assert "DEBUG: It Works!" in stderr - def test_request_with_meta(self): + def test_request_with_meta(self, proj_path: Path, mockserver: MockServer) -> None: raw_json_string = '{"foo" : "baz"}' - _, _, stderr = self.proc( + _, _, stderr = proc( "parse", "--spider", self.spider_name, @@ -192,11 +210,12 @@ ITEM_PIPELINES = {{'{self.project_name}.pipelines.MyPipeline': 1}} "-c", "parse_request_with_meta", "--verbose", - self.mockserver.url("/html"), + mockserver.url("/html"), + cwd=proj_path, ) assert "DEBUG: It Works!" in stderr - _, _, stderr = self.proc( + _, _, stderr = proc( "parse", "--spider", self.spider_name, @@ -205,13 +224,16 @@ ITEM_PIPELINES = {{'{self.project_name}.pipelines.MyPipeline': 1}} "-c", "parse_request_with_meta", "--verbose", - self.mockserver.url("/html"), + mockserver.url("/html"), + cwd=proj_path, ) assert "DEBUG: It Works!" in stderr - def test_request_with_cb_kwargs(self): + def test_request_with_cb_kwargs( + self, proj_path: Path, mockserver: MockServer + ) -> None: raw_json_string = '{"foo" : "bar", "key": "value"}' - _, _, stderr = self.proc( + _, _, stderr = proc( "parse", "--spider", self.spider_name, @@ -220,7 +242,8 @@ ITEM_PIPELINES = {{'{self.project_name}.pipelines.MyPipeline': 1}} "-c", "parse_request_with_cb_kwargs", "--verbose", - self.mockserver.url("/html"), + mockserver.url("/html"), + cwd=proj_path, ) assert "DEBUG: It Works!" in stderr assert ( @@ -228,20 +251,23 @@ ITEM_PIPELINES = {{'{self.project_name}.pipelines.MyPipeline': 1}} in stderr ) - def test_request_without_meta(self): - _, _, stderr = self.proc( + def test_request_without_meta( + self, proj_path: Path, mockserver: MockServer + ) -> None: + _, _, stderr = proc( "parse", "--spider", self.spider_name, "-c", "parse_request_without_meta", "--nolinks", - self.mockserver.url("/html"), + mockserver.url("/html"), + cwd=proj_path, ) assert "DEBUG: It Works!" in stderr - def test_pipelines(self): - _, _, stderr = self.proc( + def test_pipelines(self, proj_path: Path, mockserver: MockServer) -> None: + _, _, stderr = proc( "parse", "--spider", self.spider_name, @@ -249,163 +275,210 @@ ITEM_PIPELINES = {{'{self.project_name}.pipelines.MyPipeline': 1}} "-c", "parse", "--verbose", - self.mockserver.url("/html"), + mockserver.url("/html"), + cwd=proj_path, ) assert "INFO: It Works!" in stderr - def test_async_def_asyncio_parse_items_list(self): - _, out, stderr = self.proc( + def test_async_def_asyncio_parse_items_list( + self, proj_path: Path, mockserver: MockServer + ) -> None: + _, out, stderr = proc( "parse", "--spider", "asyncdef_asyncio_return", "-c", "parse", - self.mockserver.url("/html"), + mockserver.url("/html"), + cwd=proj_path, ) assert "INFO: Got response 200" in stderr assert "{'id': 1}" in out assert "{'id': 2}" in out - def test_async_def_asyncio_parse_items_single_element(self): - _, out, stderr = self.proc( + def test_async_def_asyncio_parse_items_single_element( + self, proj_path: Path, mockserver: MockServer + ) -> None: + _, out, stderr = proc( "parse", "--spider", "asyncdef_asyncio_return_single_element", "-c", "parse", - self.mockserver.url("/html"), + mockserver.url("/html"), + cwd=proj_path, ) assert "INFO: Got response 200" in stderr assert "{'foo': 42}" in out - def test_async_def_asyncgen_parse_loop(self): - _, out, stderr = self.proc( + def test_async_def_asyncgen_parse_loop( + self, proj_path: Path, mockserver: MockServer + ) -> None: + _, out, stderr = proc( "parse", "--spider", "asyncdef_asyncio_gen_loop", "-c", "parse", - self.mockserver.url("/html"), + mockserver.url("/html"), + cwd=proj_path, ) assert "INFO: Got response 200" in stderr for i in range(10): assert f"{{'foo': {i}}}" in out - def test_async_def_asyncgen_parse_exc(self): - _, out, stderr = self.proc( + def test_async_def_asyncgen_parse_exc( + self, proj_path: Path, mockserver: MockServer + ) -> None: + _, out, stderr = proc( "parse", "--spider", "asyncdef_asyncio_gen_exc", "-c", "parse", - self.mockserver.url("/html"), + mockserver.url("/html"), + cwd=proj_path, ) assert "ValueError" in stderr for i in range(7): assert f"{{'foo': {i}}}" in out - def test_async_def_asyncio_parse(self): - _, _, stderr = self.proc( + def test_async_def_asyncio_parse( + self, proj_path: Path, mockserver: MockServer + ) -> None: + _, _, stderr = proc( "parse", "--spider", "asyncdef_asyncio", "-c", "parse", - self.mockserver.url("/html"), + mockserver.url("/html"), + cwd=proj_path, ) assert "DEBUG: Got response 200" in stderr - def test_parse_items(self): - _, out, _ = self.proc( + def test_parse_items(self, proj_path: Path, mockserver: MockServer) -> None: + _, out, _ = proc( "parse", "--spider", self.spider_name, "-c", "parse", - self.mockserver.url("/html"), + mockserver.url("/html"), + cwd=proj_path, ) assert "[{}, {'foo': 'bar'}]" in out - def test_parse_items_no_callback_passed(self): - _, out, _ = self.proc( - "parse", "--spider", self.spider_name, self.mockserver.url("/html") + def test_parse_items_no_callback_passed( + self, proj_path: Path, mockserver: MockServer + ) -> None: + _, out, _ = proc( + "parse", + "--spider", + self.spider_name, + mockserver.url("/html"), + cwd=proj_path, ) assert "[{}, {'foo': 'bar'}]" in out - def test_wrong_callback_passed(self): - _, out, stderr = self.proc( + def test_wrong_callback_passed( + self, proj_path: Path, mockserver: MockServer + ) -> None: + _, out, stderr = proc( "parse", "--spider", self.spider_name, "-c", "dummy", - self.mockserver.url("/html"), + mockserver.url("/html"), + cwd=proj_path, ) assert re.search(r"# Scraped Items -+\r?\n\[\]", out) assert "Cannot find callback" in stderr - def test_crawlspider_matching_rule_callback_set(self): + def test_crawlspider_matching_rule_callback_set( + self, proj_path: Path, mockserver: MockServer + ) -> None: """If a rule matches the URL, use it's defined callback.""" - _, out, _ = self.proc( + _, out, _ = proc( "parse", "--spider", "goodcrawl" + self.spider_name, "-r", - self.mockserver.url("/html"), + mockserver.url("/html"), + cwd=proj_path, ) assert "[{}, {'foo': 'bar'}]" in out - def test_crawlspider_matching_rule_default_callback(self): + def test_crawlspider_matching_rule_default_callback( + self, proj_path: Path, mockserver: MockServer + ) -> None: """If a rule match but it has no callback set, use the 'parse' callback.""" - _, out, _ = self.proc( + _, out, _ = proc( "parse", "--spider", "goodcrawl" + self.spider_name, "-r", - self.mockserver.url("/text"), + mockserver.url("/text"), + cwd=proj_path, ) assert "[{}, {'nomatch': 'default'}]" in out - def test_spider_with_no_rules_attribute(self): + def test_spider_with_no_rules_attribute( + self, proj_path: Path, mockserver: MockServer + ) -> None: """Using -r with a spider with no rule should not produce items.""" - _, out, stderr = self.proc( - "parse", "--spider", self.spider_name, "-r", self.mockserver.url("/html") + _, out, stderr = proc( + "parse", + "--spider", + self.spider_name, + "-r", + mockserver.url("/html"), + cwd=proj_path, ) assert re.search(r"# Scraped Items -+\r?\n\[\]", out) assert "No CrawlSpider rules found" in stderr - def test_crawlspider_missing_callback(self): - _, out, _ = self.proc( + def test_crawlspider_missing_callback( + self, proj_path: Path, mockserver: MockServer + ) -> None: + _, out, _ = proc( "parse", "--spider", "badcrawl" + self.spider_name, "-r", - self.mockserver.url("/html"), + mockserver.url("/html"), + cwd=proj_path, ) assert re.search(r"# Scraped Items -+\r?\n\[\]", out) - def test_crawlspider_no_matching_rule(self): + def test_crawlspider_no_matching_rule( + self, proj_path: Path, mockserver: MockServer + ) -> None: """The requested URL has no matching rule, so no items should be scraped""" - _, out, stderr = self.proc( + _, out, stderr = proc( "parse", "--spider", "badcrawl" + self.spider_name, "-r", - self.mockserver.url("/enc-gb18030"), + mockserver.url("/enc-gb18030"), + cwd=proj_path, ) assert re.search(r"# Scraped Items -+\r?\n\[\]", out) assert "Cannot find a rule that matches" in stderr - def test_crawlspider_not_exists_with_not_matched_url(self): - assert self.call("parse", self.mockserver.url("/invalid_url")) == 0 + def test_crawlspider_not_exists_with_not_matched_url( + self, proj_path: Path, mockserver: MockServer + ) -> None: + assert call("parse", mockserver.url("/invalid_url"), cwd=proj_path) == 0 - def test_output_flag(self): + def test_output_flag(self, proj_path: Path, mockserver: MockServer) -> None: """Checks if a file was created successfully having correct format containing correct data in it. """ file_name = "data.json" - file_path = Path(self.proj_path, file_name) - self.proc( + file_path = proj_path / file_name + proc( "parse", "--spider", self.spider_name, @@ -413,7 +486,8 @@ ITEM_PIPELINES = {{'{self.project_name}.pipelines.MyPipeline': 1}} "parse", "-o", file_name, - self.mockserver.url("/html"), + mockserver.url("/html"), + cwd=proj_path, ) assert file_path.exists() diff --git a/tests/test_command_runspider.py b/tests/test_command_runspider.py index c1a6d9b18..2fa4ce581 100644 --- a/tests/test_command_runspider.py +++ b/tests/test_command_runspider.py @@ -1,23 +1,22 @@ from __future__ import annotations +import asyncio import inspect import platform import sys -from contextlib import contextmanager -from pathlib import Path -from tempfile import TemporaryDirectory, mkdtemp from typing import TYPE_CHECKING import pytest -from tests.test_commands import TestCommandBase from tests.test_crawler import ExceptionSpider, NoRequestsSpider +from tests.utils.cmdline import proc if TYPE_CHECKING: - from collections.abc import Iterator + from collections.abc import Iterable + from pathlib import Path -class TestRunSpiderCommand(TestCommandBase): +class TestRunSpiderCommand: spider_filename = "myspider.py" debug_log_spider = """ @@ -42,26 +41,21 @@ class BadSpider(scrapy.Spider): yield """ - @contextmanager - def _create_file(self, content: str, name: str | None = None) -> Iterator[str]: - with TemporaryDirectory() as tmpdir: - if name: - fname = Path(tmpdir, name).resolve() - else: - fname = Path(tmpdir, self.spider_filename).resolve() - fname.write_text(content, encoding="utf-8") - yield str(fname) + def runspider( + self, cwd: Path, code: str, name: str | None = None, args: Iterable[str] = () + ) -> tuple[int, str, str]: + fname = cwd / (name or self.spider_filename) + fname.write_text(code, encoding="utf-8") + return proc("runspider", str(fname), *args, cwd=cwd) - def runspider(self, code, name=None, args=()): - with self._create_file(code, name) as fname: - return self.proc("runspider", fname, *args) - - def get_log(self, code, name=None, args=()): - _, _, stderr = self.runspider(code, name, args=args) + def get_log( + self, cwd: Path, code: str, name: str | None = None, args: Iterable[str] = () + ) -> str: + _, _, stderr = self.runspider(cwd, code, name, args=args) return stderr - def test_runspider(self): - log = self.get_log(self.debug_log_spider) + def test_runspider(self, tmp_path: Path) -> None: + log = self.get_log(tmp_path, self.debug_log_spider) assert "DEBUG: It Works!" in log assert ( "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" @@ -69,27 +63,29 @@ class BadSpider(scrapy.Spider): ) assert "INFO: Spider closed (finished)" in log - def test_run_fail_spider(self): - proc, _, _ = self.runspider( - "import scrapy\n" + inspect.getsource(ExceptionSpider) + def test_run_fail_spider(self, tmp_path: Path) -> None: + ret, _, _ = self.runspider( + tmp_path, "import scrapy\n" + inspect.getsource(ExceptionSpider) ) - ret = proc.returncode assert ret != 0 - def test_run_good_spider(self): - proc, _, _ = self.runspider( - "import scrapy\n" + inspect.getsource(NoRequestsSpider) + def test_run_good_spider(self, tmp_path: Path) -> None: + ret, _, _ = self.runspider( + tmp_path, "import scrapy\n" + inspect.getsource(NoRequestsSpider) ) - ret = proc.returncode assert ret == 0 - def test_runspider_log_level(self): - log = self.get_log(self.debug_log_spider, args=("-s", "LOG_LEVEL=INFO")) + def test_runspider_log_level(self, tmp_path: Path) -> None: + log = self.get_log( + tmp_path, self.debug_log_spider, args=("-s", "LOG_LEVEL=INFO") + ) assert "DEBUG: It Works!" not in log assert "INFO: Spider opened" in log - def test_runspider_default_reactor(self): - log = self.get_log(self.debug_log_spider, args=("-s", "TWISTED_REACTOR=")) + def test_runspider_default_reactor(self, tmp_path: Path) -> None: + log = self.get_log( + tmp_path, self.debug_log_spider, args=("-s", "TWISTED_REACTOR=") + ) assert "DEBUG: It Works!" in log assert ( "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" @@ -99,7 +95,7 @@ class BadSpider(scrapy.Spider): assert "INFO: Closing spider (finished)" in log assert "INFO: Spider closed (finished)" in log - def test_runspider_dnscache_disabled(self): + def test_runspider_dnscache_disabled(self, tmp_path: Path) -> None: # see https://github.com/scrapy/scrapy/issues/2811 # The spider below should not be able to connect to localhost:12345, # which is intended, @@ -112,43 +108,49 @@ class MySpider(scrapy.Spider): name = 'myspider' start_urls = ['http://localhost:12345'] + custom_settings = { + "ROBOTSTXT_OBEY": False, + "RETRY_ENABLED": False, + } + def parse(self, response): return {'test': 'value'} """ - log = self.get_log(dnscache_spider, args=("-s", "DNSCACHE_ENABLED=False")) + log = self.get_log( + tmp_path, dnscache_spider, args=("-s", "DNSCACHE_ENABLED=False") + ) assert "DNSLookupError" not in log assert "INFO: Spider opened" in log - def test_runspider_log_short_names(self): - log1 = self.get_log(self.debug_log_spider, args=("-s", "LOG_SHORT_NAMES=1")) + @pytest.mark.parametrize("value", [False, True]) + def test_runspider_log_short_names(self, tmp_path: Path, value: bool) -> None: + log1 = self.get_log( + tmp_path, self.debug_log_spider, args=("-s", f"LOG_SHORT_NAMES={value}") + ) assert "[myspider] DEBUG: It Works!" in log1 - assert "[scrapy]" in log1 - assert "[scrapy.core.engine]" not in log1 + assert ("[scrapy]" in log1) is value + assert ("[scrapy.core.engine]" in log1) is not value - log2 = self.get_log(self.debug_log_spider, args=("-s", "LOG_SHORT_NAMES=0")) - assert "[myspider] DEBUG: It Works!" in log2 - assert "[scrapy]" not in log2 - assert "[scrapy.core.engine]" in log2 - - def test_runspider_no_spider_found(self): - log = self.get_log("from scrapy.spiders import Spider\n") + def test_runspider_no_spider_found(self, tmp_path: Path) -> None: + log = self.get_log(tmp_path, "from scrapy.spiders import Spider\n") assert "No spider found in file" in log - def test_runspider_file_not_found(self): - _, _, log = self.proc("runspider", "some_non_existent_file") + def test_runspider_file_not_found(self) -> None: + _, _, log = proc("runspider", "some_non_existent_file") assert "File not found: some_non_existent_file" in log - def test_runspider_unable_to_load(self): - log = self.get_log("", name="myspider.txt") + def test_runspider_unable_to_load(self, tmp_path: Path) -> None: + log = self.get_log(tmp_path, "", name="myspider.txt") assert "Unable to load" in log - def test_start_errors(self): - log = self.get_log(self.badspider, name="badspider.py") + def test_start_errors(self, tmp_path: Path) -> None: + log = self.get_log(tmp_path, self.badspider, name="badspider.py") assert "start" in log assert "badspider.py" in log, log - def test_asyncio_enabled_true(self): + def test_asyncio_enabled_true(self, tmp_path: Path) -> None: log = self.get_log( + tmp_path, self.debug_log_spider, args=[ "-s", @@ -160,15 +162,16 @@ class MySpider(scrapy.Spider): in log ) - def test_asyncio_enabled_default(self): - log = self.get_log(self.debug_log_spider, args=[]) + def test_asyncio_enabled_default(self, tmp_path: Path) -> None: + log = self.get_log(tmp_path, self.debug_log_spider) assert ( "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" in log ) - def test_asyncio_enabled_false(self): + def test_asyncio_enabled_false(self, tmp_path: Path) -> None: log = self.get_log( + tmp_path, self.debug_log_spider, args=["-s", "TWISTED_REACTOR=twisted.internet.selectreactor.SelectReactor"], ) @@ -179,8 +182,9 @@ class MySpider(scrapy.Spider): ) @pytest.mark.requires_uvloop - def test_custom_asyncio_loop_enabled_true(self): + def test_custom_asyncio_loop_enabled_true(self, tmp_path: Path) -> None: log = self.get_log( + tmp_path, self.debug_log_spider, args=[ "-s", @@ -191,16 +195,15 @@ class MySpider(scrapy.Spider): ) assert "Using asyncio event loop: uvloop.Loop" in log - def test_custom_asyncio_loop_enabled_false(self): + def test_custom_asyncio_loop_enabled_false(self, tmp_path: Path) -> None: log = self.get_log( + tmp_path, self.debug_log_spider, args=[ "-s", "TWISTED_REACTOR=twisted.internet.asyncioreactor.AsyncioSelectorReactor", ], ) - import asyncio - if sys.platform != "win32": loop = asyncio.new_event_loop() else: @@ -210,7 +213,7 @@ class MySpider(scrapy.Spider): in log ) - def test_output(self): + def test_output(self, tmp_path: Path) -> None: spider_code = """ import scrapy @@ -223,10 +226,10 @@ class MySpider(scrapy.Spider): yield """ args = ["-o", "example.json"] - log = self.get_log(spider_code, args=args) + log = self.get_log(tmp_path, spider_code, args=args) assert "[myspider] DEBUG: FEEDS: {'example.json': {'format': 'json'}}" in log - def test_overwrite_output(self): + def test_overwrite_output(self, tmp_path: Path) -> None: spider_code = """ import json import scrapy @@ -243,18 +246,18 @@ class MySpider(scrapy.Spider): return yield """ - Path(self.cwd, "example.json").write_text("not empty", encoding="utf-8") + (tmp_path / "example.json").write_text("not empty", encoding="utf-8") args = ["-O", "example.json"] - log = self.get_log(spider_code, args=args) + log = self.get_log(tmp_path, spider_code, args=args) assert ( '[myspider] DEBUG: FEEDS: {"example.json": {"format": "json", "overwrite": true}}' in log ) - with Path(self.cwd, "example.json").open(encoding="utf-8") as f2: + with (tmp_path / "example.json").open(encoding="utf-8") as f2: first_line = f2.readline() assert first_line != "not empty" - def test_output_and_overwrite_output(self): + def test_output_and_overwrite_output(self, tmp_path: Path) -> None: spider_code = """ import scrapy @@ -266,12 +269,12 @@ class MySpider(scrapy.Spider): yield """ args = ["-o", "example1.json", "-O", "example2.json"] - log = self.get_log(spider_code, args=args) + log = self.get_log(tmp_path, spider_code, args=args) assert ( "error: Please use only one of -o/--output and -O/--overwrite-output" in log ) - def test_output_stdout(self): + def test_output_stdout(self, tmp_path: Path) -> None: spider_code = """ import scrapy @@ -284,11 +287,11 @@ class MySpider(scrapy.Spider): yield """ args = ["-o", "-:json"] - log = self.get_log(spider_code, args=args) + log = self.get_log(tmp_path, spider_code, args=args) assert "[myspider] DEBUG: FEEDS: {'stdout:': {'format': 'json'}}" in log - @pytest.mark.skipif(platform.system() == "Windows", reason="Linux only") - def test_absolute_path_linux(self): + @pytest.mark.parametrize("arg", ["output.json:json", "output.json"]) + def test_absolute_path(self, tmp_path: Path, arg: str) -> None: spider_code = """ import scrapy @@ -300,52 +303,15 @@ class MySpider(scrapy.Spider): def parse(self, response): yield {"hello": "world"} """ - temp_dir = mkdtemp() - args = ["-o", f"{temp_dir}/output1.json:json"] - log = self.get_log(spider_code, args=args) + args = ["-o", str(tmp_path / arg)] + log = self.get_log(tmp_path, spider_code, args=args) assert ( - f"[scrapy.extensions.feedexport] INFO: Stored json feed (1 items) in: {temp_dir}/output1.json" + f"[scrapy.extensions.feedexport] INFO: Stored json feed (1 items) in: {tmp_path / 'output.json'}" in log ) - args = ["-o", f"{temp_dir}/output2.json"] - log = self.get_log(spider_code, args=args) - assert ( - f"[scrapy.extensions.feedexport] INFO: Stored json feed (1 items) in: {temp_dir}/output2.json" - in log - ) - - @pytest.mark.skipif(platform.system() != "Windows", reason="Windows only") - def test_absolute_path_windows(self): - spider_code = """ -import scrapy - -class MySpider(scrapy.Spider): - name = 'myspider' - - start_urls = ["data:,"] - - def parse(self, response): - yield {"hello": "world"} - """ - temp_dir = mkdtemp() - - args = ["-o", f"{temp_dir}\\output1.json:json"] - log = self.get_log(spider_code, args=args) - assert ( - f"[scrapy.extensions.feedexport] INFO: Stored json feed (1 items) in: {temp_dir}\\output1.json" - in log - ) - - args = ["-o", f"{temp_dir}\\output2.json"] - log = self.get_log(spider_code, args=args) - assert ( - f"[scrapy.extensions.feedexport] INFO: Stored json feed (1 items) in: {temp_dir}\\output2.json" - in log - ) - - def test_args_change_settings(self): + def test_args_change_settings(self, tmp_path: Path) -> None: spider_code = """ import scrapy @@ -364,7 +330,7 @@ class MySpider(scrapy.Spider): yield """ args = ["-a", "foo=42"] - log = self.get_log(spider_code, args=args) + log = self.get_log(tmp_path, spider_code, args=args) assert "Spider closed (finished)" in log assert "The value of FOO is 42" in log @@ -375,10 +341,10 @@ class MySpider(scrapy.Spider): class TestWindowsRunSpiderCommand(TestRunSpiderCommand): spider_filename = "myspider.pyw" - def test_start_errors(self): - log = self.get_log(self.badspider, name="badspider.pyw") + def test_start_errors(self, tmp_path: Path) -> None: + log = self.get_log(tmp_path, self.badspider, name="badspider.pyw") assert "start" in log assert "badspider.pyw" in log - def test_runspider_unable_to_load(self): + def test_runspider_unable_to_load(self, tmp_path: Path) -> None: pytest.skip("Already Tested in 'RunSpiderCommandTest'") diff --git a/tests/test_command_shell.py b/tests/test_command_shell.py index 76c1eb663..d9fb96fb8 100644 --- a/tests/test_command_shell.py +++ b/tests/test_command_shell.py @@ -1,145 +1,133 @@ +from __future__ import annotations + import os import sys from io import BytesIO from pathlib import Path +from typing import TYPE_CHECKING, cast import pytest from pexpect.popen_spawn import PopenSpawn from scrapy.utils.reactor import _asyncio_reactor_path from tests import NON_EXISTING_RESOLVABLE, tests_datadir -from tests.mockserver import MockServer -from tests.test_commands import TestProjectBase +from tests.utils.cmdline import proc + +if TYPE_CHECKING: + from tests.mockserver.http import MockServer -class TestShellCommand(TestProjectBase): - @classmethod - def setup_class(cls): - cls.mockserver = MockServer() - cls.mockserver.__enter__() - - @classmethod - def teardown_class(cls): - cls.mockserver.__exit__(None, None, None) - - def test_empty(self): - _, out, _ = self.proc("shell", "-c", "item") +class TestShellCommand: + def test_empty(self) -> None: + _, out, _ = proc("shell", "-c", "item") assert "{}" in out - def test_response_body(self): - _, out, _ = self.proc( - "shell", self.mockserver.url("/text"), "-c", "response.body" - ) + def test_response_body(self, mockserver: MockServer) -> None: + _, out, _ = proc("shell", mockserver.url("/text"), "-c", "response.body") assert "Works" in out - def test_response_type_text(self): - _, out, _ = self.proc( - "shell", self.mockserver.url("/text"), "-c", "type(response)" - ) + def test_response_type_text(self, mockserver: MockServer) -> None: + _, out, _ = proc("shell", mockserver.url("/text"), "-c", "type(response)") assert "TextResponse" in out - def test_response_type_html(self): - _, out, _ = self.proc( - "shell", self.mockserver.url("/html"), "-c", "type(response)" - ) + def test_response_type_html(self, mockserver: MockServer) -> None: + _, out, _ = proc("shell", mockserver.url("/html"), "-c", "type(response)") assert "HtmlResponse" in out - def test_response_selector_html(self): + def test_response_selector_html(self, mockserver: MockServer) -> None: xpath = "response.xpath(\"//p[@class='one']/text()\").get()" - _, out, _ = self.proc("shell", self.mockserver.url("/html"), "-c", xpath) + _, out, _ = proc("shell", mockserver.url("/html"), "-c", xpath) assert out.strip() == "Works" - def test_response_encoding_gb18030(self): - _, out, _ = self.proc( - "shell", self.mockserver.url("/enc-gb18030"), "-c", "response.encoding" + def test_response_encoding_gb18030(self, mockserver: MockServer) -> None: + _, out, _ = proc( + "shell", mockserver.url("/enc-gb18030"), "-c", "response.encoding" ) assert out.strip() == "gb18030" - def test_redirect(self): - _, out, _ = self.proc( - "shell", self.mockserver.url("/redirect"), "-c", "response.url" - ) + def test_redirect(self, mockserver: MockServer) -> None: + _, out, _ = proc("shell", mockserver.url("/redirect"), "-c", "response.url") assert out.strip().endswith("/redirected") - def test_redirect_follow_302(self): - _, out, _ = self.proc( + def test_redirect_follow_302(self, mockserver: MockServer) -> None: + _, out, _ = proc( "shell", - self.mockserver.url("/redirect-no-meta-refresh"), + mockserver.url("/redirect-no-meta-refresh"), "-c", "response.status", ) assert out.strip().endswith("200") - def test_redirect_not_follow_302(self): - _, out, _ = self.proc( + def test_redirect_not_follow_302(self, mockserver: MockServer) -> None: + _, out, _ = proc( "shell", "--no-redirect", - self.mockserver.url("/redirect-no-meta-refresh"), + mockserver.url("/redirect-no-meta-refresh"), "-c", "response.status", ) assert out.strip().endswith("302") - def test_fetch_redirect_follow_302(self): + def test_fetch_redirect_follow_302(self, mockserver: MockServer) -> None: """Test that calling ``fetch(url)`` follows HTTP redirects by default.""" - url = self.mockserver.url("/redirect-no-meta-refresh") + url = mockserver.url("/redirect-no-meta-refresh") code = f"fetch('{url}')" - p, out, errout = self.proc("shell", "-c", code) - assert p.returncode == 0, out - assert "Redirecting (302)" in errout - assert "Crawled (200)" in errout + ret, out, err = proc("shell", "-c", code) + assert ret == 0, out + assert "Redirecting (302)" in err + assert "Crawled (200)" in err - def test_fetch_redirect_not_follow_302(self): + def test_fetch_redirect_not_follow_302(self, mockserver: MockServer) -> None: """Test that calling ``fetch(url, redirect=False)`` disables automatic redirects.""" - url = self.mockserver.url("/redirect-no-meta-refresh") + url = mockserver.url("/redirect-no-meta-refresh") code = f"fetch('{url}', redirect=False)" - p, out, errout = self.proc("shell", "-c", code) - assert p.returncode == 0, out - assert "Crawled (302)" in errout + ret, out, err = proc("shell", "-c", code) + assert ret == 0, out + assert "Crawled (302)" in err - def test_request_replace(self): - url = self.mockserver.url("/text") + def test_request_replace(self, mockserver: MockServer) -> None: + url = mockserver.url("/text") code = f"fetch('{url}') or fetch(response.request.replace(method='POST'))" - p, out, _ = self.proc("shell", "-c", code) - assert p.returncode == 0, out + ret, out, _ = proc("shell", "-c", code) + assert ret == 0, out - def test_scrapy_import(self): - url = self.mockserver.url("/text") + def test_scrapy_import(self, mockserver: MockServer) -> None: + url = mockserver.url("/text") code = f"fetch(scrapy.Request('{url}'))" - p, out, _ = self.proc("shell", "-c", code) - assert p.returncode == 0, out + ret, out, _ = proc("shell", "-c", code) + assert ret == 0, out - def test_local_file(self): + def test_local_file(self) -> None: filepath = Path(tests_datadir, "test_site", "index.html") - _, out, _ = self.proc("shell", str(filepath), "-c", "item") + _, out, _ = proc("shell", str(filepath), "-c", "item") assert "{}" in out - def test_local_nofile(self): + def test_local_nofile(self) -> None: filepath = "file:///tests/sample_data/test_site/nothinghere.html" - p, out, err = self.proc("shell", filepath, "-c", "item") - assert p.returncode == 1, out or err + ret, out, err = proc("shell", filepath, "-c", "item") + assert ret == 1, out or err assert "No such file or directory" in err - def test_dns_failures(self): + def test_dns_failures(self, mockserver: MockServer) -> None: if NON_EXISTING_RESOLVABLE: pytest.skip("Non-existing hosts are resolvable") url = "www.somedomainthatdoesntexi.st" - p, out, err = self.proc("shell", url, "-c", "item") - assert p.returncode == 1, out or err + ret, out, err = proc("shell", url, "-c", "item") + assert ret == 1, out or err assert "DNS lookup failed" in err - def test_shell_fetch_async(self): - url = self.mockserver.url("/html") + def test_shell_fetch_async(self, mockserver: MockServer) -> None: + url = mockserver.url("/html") code = f"fetch('{url}')" - p, _, err = self.proc( + ret, _, err = proc( "shell", "-c", code, "--set", f"TWISTED_REACTOR={_asyncio_reactor_path}" ) - assert p.returncode == 0, err + assert ret == 0, err assert "RuntimeError: There is no current event loop in thread" not in err class TestInteractiveShell: - def test_fetch(self): + def test_fetch(self, mockserver: MockServer) -> None: args = ( sys.executable, "-m", @@ -149,13 +137,13 @@ class TestInteractiveShell: env = os.environ.copy() env["SCRAPY_PYTHON_SHELL"] = "python" logfile = BytesIO() - p = PopenSpawn(args, env=env, timeout=5) + # https://github.com/python/typeshed/issues/14915 + p = PopenSpawn(args, env=cast("os._Environ", env), timeout=5) p.logfile_read = logfile p.expect_exact("Available Scrapy objects") - with MockServer() as mockserver: - p.sendline(f"fetch('{mockserver.url('/')}')") - p.sendline("type(response)") - p.expect_exact("HtmlResponse") + p.sendline(f"fetch('{mockserver.url('/')}')") + p.sendline("type(response)") + p.expect_exact("HtmlResponse") p.sendeof() p.wait() logfile.seek(0) diff --git a/tests/test_command_startproject.py b/tests/test_command_startproject.py index 988ad50b9..4eed09fcd 100644 --- a/tests/test_command_startproject.py +++ b/tests/test_command_startproject.py @@ -8,74 +8,74 @@ from itertools import chain from pathlib import Path from shutil import copytree from stat import S_IWRITE as ANYONE_WRITE_PERMISSION -from tempfile import mkdtemp import scrapy from scrapy.commands.startproject import IGNORE -from tests.test_commands import TestProjectBase +from scrapy.utils.test import get_testenv +from tests.utils.cmdline import call, proc -class TestStartprojectCommand(TestProjectBase): - def test_startproject(self): - p, out, err = self.proc("startproject", self.project_name) - print(out) - print(err, file=sys.stderr) - assert p.returncode == 0 +class TestStartprojectCommand: + project_name = "testproject" - assert Path(self.proj_path, "scrapy.cfg").exists() - assert Path(self.proj_path, "testproject").exists() - assert Path(self.proj_mod_path, "__init__.py").exists() - assert Path(self.proj_mod_path, "items.py").exists() - assert Path(self.proj_mod_path, "pipelines.py").exists() - assert Path(self.proj_mod_path, "settings.py").exists() - assert Path(self.proj_mod_path, "spiders", "__init__.py").exists() + @staticmethod + def _assert_files_exist(project_dir: Path, project_name: str) -> None: + assert (project_dir / "scrapy.cfg").exists() + assert (project_dir / project_name).exists() + assert (project_dir / project_name / "__init__.py").exists() + assert (project_dir / project_name / "items.py").exists() + assert (project_dir / project_name / "pipelines.py").exists() + assert (project_dir / project_name / "settings.py").exists() + assert (project_dir / project_name / "spiders" / "__init__.py").exists() - assert self.call("startproject", self.project_name) == 1 - assert self.call("startproject", "wrong---project---name") == 1 - assert self.call("startproject", "sys") == 1 + def test_startproject(self, tmp_path: Path) -> None: + # with no dir argument creates the project in the "self.project_name" subdir of cwd + assert call("startproject", self.project_name, cwd=tmp_path) == 0 + self._assert_files_exist(tmp_path / self.project_name, self.project_name) - def test_startproject_with_project_dir(self): - project_dir = mkdtemp() - assert self.call("startproject", self.project_name, project_dir) == 0 + assert call("startproject", self.project_name, cwd=tmp_path) == 1 + assert call("startproject", "wrong---project---name") == 1 + assert call("startproject", "sys") == 1 - assert Path(project_dir, "scrapy.cfg").exists() - assert Path(project_dir, "testproject").exists() - assert Path(project_dir, self.project_name, "__init__.py").exists() - assert Path(project_dir, self.project_name, "items.py").exists() - assert Path(project_dir, self.project_name, "pipelines.py").exists() - assert Path(project_dir, self.project_name, "settings.py").exists() - assert Path(project_dir, self.project_name, "spiders", "__init__.py").exists() - - assert self.call("startproject", self.project_name, project_dir + "2") == 0 - - assert self.call("startproject", self.project_name, project_dir) == 1 - assert self.call("startproject", self.project_name + "2", project_dir) == 1 - assert self.call("startproject", "wrong---project---name") == 1 - assert self.call("startproject", "sys") == 1 - assert self.call("startproject") == 2 + def test_startproject_with_project_dir(self, tmp_path: Path) -> None: + # with a dir arg creates the project in the specified dir + project_dir = tmp_path / "project" assert ( - self.call("startproject", self.project_name, project_dir, "another_params") + call("startproject", self.project_name, str(project_dir), cwd=tmp_path) == 0 + ) + self._assert_files_exist(project_dir, self.project_name) + + assert ( + call( + "startproject", self.project_name, str(project_dir) + "2", cwd=tmp_path + ) + == 0 + ) + + assert ( + call("startproject", self.project_name, str(project_dir), cwd=tmp_path) == 1 + ) + assert ( + call( + "startproject", self.project_name + "2", str(project_dir), cwd=tmp_path + ) + == 1 + ) + assert call("startproject", "wrong---project---name") == 1 + assert call("startproject", "sys") == 1 + assert call("startproject") == 2 + assert ( + call("startproject", self.project_name, str(project_dir), "another_params") == 2 ) - def test_existing_project_dir(self): - project_dir = mkdtemp() + def test_existing_project_dir(self, tmp_path: Path) -> None: project_name = self.project_name + "_existing" - project_path = Path(project_dir, project_name) + project_path = tmp_path / project_name project_path.mkdir() - p, out, err = self.proc("startproject", project_name, cwd=project_dir) - print(out) - print(err, file=sys.stderr) - assert p.returncode == 0 - - assert Path(project_path, "scrapy.cfg").exists() - assert Path(project_path, project_name).exists() - assert Path(project_path, project_name, "__init__.py").exists() - assert Path(project_path, project_name, "items.py").exists() - assert Path(project_path, project_name, "pipelines.py").exists() - assert Path(project_path, project_name, "settings.py").exists() - assert Path(project_path, project_name, "spiders", "__init__.py").exists() + assert call("startproject", project_name, cwd=tmp_path) == 0 + self._assert_files_exist(project_path, project_name) def get_permissions_dict( @@ -105,28 +105,22 @@ def get_permissions_dict( return permissions_dict -class TestStartprojectTemplates(TestProjectBase): - maxDiff = None +class TestStartprojectTemplates: + def test_startproject_template_override(self, tmp_path: Path) -> None: + tmpl = tmp_path / "templates" + tmpl_proj = tmpl / "project" + project_name = "testproject" - def setup_method(self): - super().setup_method() - self.tmpl = str(Path(self.temp_path, "templates")) - self.tmpl_proj = str(Path(self.tmpl, "project")) + copytree(Path(scrapy.__path__[0], "templates"), tmpl) + (tmpl_proj / "root_template").write_bytes(b"") - def test_startproject_template_override(self): - copytree(Path(scrapy.__path__[0], "templates"), self.tmpl) - Path(self.tmpl_proj, "root_template").write_bytes(b"") - assert Path(self.tmpl_proj, "root_template").exists() + args = ["--set", f"TEMPLATES_DIR={tmpl}"] + _, out, _ = proc("startproject", project_name, *args, cwd=tmp_path) + assert f"New Scrapy project '{project_name}', using template directory" in out + assert str(tmpl_proj) in out + assert (tmp_path / project_name / "root_template").exists() - args = ["--set", f"TEMPLATES_DIR={self.tmpl}"] - p, out, err = self.proc("startproject", self.project_name, *args) - assert ( - f"New Scrapy project '{self.project_name}', using template directory" in out - ) - assert self.tmpl_proj in out - assert Path(self.proj_path, "root_template").exists() - - def test_startproject_permissions_from_writable(self): + def test_startproject_permissions_from_writable(self, tmp_path: Path) -> None: """Check that generated files have the right permissions when the template folder has the same permissions as in the project, i.e. everything is writable.""" @@ -143,7 +137,8 @@ class TestStartprojectTemplates(TestProjectBase): IGNORE, ) - destination = mkdtemp() + destination = tmp_path / "proj" + destination.mkdir() process = subprocess.Popen( ( sys.executable, @@ -153,16 +148,18 @@ class TestStartprojectTemplates(TestProjectBase): project_name, ), cwd=destination, - env=self.env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + env=get_testenv(), ) process.wait() - project_dir = Path(destination, project_name) + project_dir = destination / project_name actual_permissions = get_permissions_dict(project_dir) assert actual_permissions == expected_permissions - def test_startproject_permissions_from_read_only(self): + def test_startproject_permissions_from_read_only(self, tmp_path: Path) -> None: """Check that generated files have the right permissions when the template folder has been made read-only, which is something that some systems do. @@ -187,35 +184,34 @@ class TestStartprojectTemplates(TestProjectBase): current_permissions = path.stat().st_mode path.chmod(current_permissions & ~ANYONE_WRITE_PERMISSION) - read_only_templates_dir = str(Path(mkdtemp()) / "templates") + read_only_templates_dir = tmp_path / "templates" copytree(templates_dir, read_only_templates_dir) for root, dirs, files in os.walk(read_only_templates_dir): for node in chain(dirs, files): _make_read_only(Path(root, node)) - destination = mkdtemp() - process = subprocess.Popen( - ( - sys.executable, - "-m", - "scrapy.cmdline", + destination = tmp_path / "proj" + destination.mkdir() + assert ( + call( "startproject", project_name, "--set", f"TEMPLATES_DIR={read_only_templates_dir}", - ), - cwd=destination, - env=self.env, + cwd=destination, + ) + == 0 ) - process.wait() - project_dir = Path(destination, project_name) + project_dir = destination / project_name actual_permissions = get_permissions_dict(project_dir) assert actual_permissions == expected_permissions - def test_startproject_permissions_unchanged_in_destination(self): + def test_startproject_permissions_unchanged_in_destination( + self, tmp_path: Path + ) -> None: """Check that preexisting folders and files in the destination folder do not see their permissions modified.""" scrapy_path = scrapy.__path__[0] @@ -231,11 +227,12 @@ class TestStartprojectTemplates(TestProjectBase): IGNORE, ) - destination = mkdtemp() - project_dir = Path(destination, project_name) + destination = tmp_path / "proj" + project_dir = destination / project_name + project_dir.mkdir(parents=True) existing_nodes = { - oct(permissions)[2:] + extension: permissions + f"{permissions:o}{extension}": permissions for extension in ("", ".d") for permissions in ( 0o444, @@ -246,7 +243,6 @@ class TestStartprojectTemplates(TestProjectBase): 0o777, ) } - project_dir.mkdir() for node, permissions in existing_nodes.items(): path = project_dir / node if node.endswith(".d"): @@ -255,25 +251,13 @@ class TestStartprojectTemplates(TestProjectBase): path.touch(mode=permissions) expected_permissions[node] = oct(path.stat().st_mode) - process = subprocess.Popen( - ( - sys.executable, - "-m", - "scrapy.cmdline", - "startproject", - project_name, - ".", - ), - cwd=project_dir, - env=self.env, - ) - process.wait() + assert call("startproject", project_name, ".", cwd=project_dir) == 0 actual_permissions = get_permissions_dict(project_dir) assert actual_permissions == expected_permissions - def test_startproject_permissions_umask_022(self): + def test_startproject_permissions_umask_022(self, tmp_path: Path) -> None: """Check that generated files have the right permissions when the system uses a umask value that causes new files to have different permissions than those from the template folder.""" @@ -298,21 +282,11 @@ class TestStartprojectTemplates(TestProjectBase): ) with umask(0o002): - destination = mkdtemp() - process = subprocess.Popen( - ( - sys.executable, - "-m", - "scrapy.cmdline", - "startproject", - project_name, - ), - cwd=destination, - env=self.env, - ) - process.wait() + destination = tmp_path / "proj" + destination.mkdir() + assert call("startproject", project_name, cwd=destination) == 0 - project_dir = Path(destination, project_name) + project_dir = destination / project_name actual_permissions = get_permissions_dict(project_dir) assert actual_permissions == expected_permissions diff --git a/tests/test_command_version.py b/tests/test_command_version.py index de58203fc..c4ca9d07b 100644 --- a/tests/test_command_version.py +++ b/tests/test_command_version.py @@ -1,14 +1,14 @@ import scrapy -from tests.test_commands import TestProjectBase +from tests.utils.cmdline import proc -class TestVersionCommand(TestProjectBase): - def test_output(self): - _, out, _ = self.proc("version") +class TestVersionCommand: + def test_output(self) -> None: + _, out, _ = proc("version") assert out.strip() == f"Scrapy {scrapy.__version__}" - def test_verbose_output(self): - _, out, _ = self.proc("version", "-v") + def test_verbose_output(self) -> None: + _, out, _ = proc("version", "-v") headers = [line.partition(":")[0].strip() for line in out.strip().splitlines()] assert headers == [ "Scrapy", diff --git a/tests/test_commands.py b/tests/test_commands.py index 851c92db4..07bd0b45c 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -2,32 +2,35 @@ from __future__ import annotations import argparse import json -import re -import subprocess -import sys from io import StringIO -from pathlib import Path -from shutil import rmtree -from tempfile import TemporaryFile, mkdtemp -from threading import Timer -from typing import TYPE_CHECKING, Any +from shutil import copytree +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.commands import ScrapyCommand, ScrapyHelpFormatter, view from scrapy.settings import Settings -from scrapy.utils.python import to_unicode from scrapy.utils.reactor import _asyncio_reactor_path -from scrapy.utils.test import get_testenv +from tests.utils.cmdline import call, proc if TYPE_CHECKING: - import os + from pathlib import Path + + +class EmptyCommand(ScrapyCommand): + def short_desc(self) -> str: + return "" + + def run(self, args: list[str], opts: argparse.Namespace) -> None: + pass class TestCommandSettings: def setup_method(self): - self.command = ScrapyCommand() + self.command = EmptyCommand() self.command.settings = Settings() self.parser = argparse.ArgumentParser( formatter_class=ScrapyHelpFormatter, conflict_handler="resolve" @@ -60,100 +63,40 @@ class TestCommandSettings: class TestProjectBase: + """A base class for tests that may need a Scrapy project.""" + project_name = "testproject" - def setup_method(self): - self.temp_path = mkdtemp() - self.cwd = self.temp_path - self.proj_path = Path(self.temp_path, self.project_name) - self.proj_mod_path = self.proj_path / self.project_name - self.env = get_testenv() + @pytest.fixture(scope="session") + def _proj_path_cached(self, tmp_path_factory: pytest.TempPathFactory) -> Path: + """Create a Scrapy project in a temporary directory and return its path. - def teardown_method(self): - rmtree(self.temp_path) + Used as a cache for ``proj_path``. + """ + tmp_path = tmp_path_factory.mktemp("proj") + call("startproject", self.project_name, cwd=tmp_path) + return tmp_path / self.project_name - def call(self, *args: str, **popen_kwargs: Any) -> int: - with TemporaryFile() as out: - args = (sys.executable, "-m", "scrapy.cmdline", *args) - return subprocess.call( - args, stdout=out, stderr=out, cwd=self.cwd, env=self.env, **popen_kwargs - ) - - def proc( - self, *args: str, **popen_kwargs: Any - ) -> tuple[subprocess.Popen[bytes], str, str]: - args = (sys.executable, "-m", "scrapy.cmdline", *args) - p = subprocess.Popen( - args, - cwd=popen_kwargs.pop("cwd", self.cwd), - env=self.env, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - **popen_kwargs, - ) - - def kill_proc(): - p.kill() - p.communicate() - raise AssertionError("Command took too much time to complete") - - timer = Timer(15, kill_proc) - try: - timer.start() - stdout, stderr = p.communicate() - finally: - timer.cancel() - - return p, to_unicode(stdout), to_unicode(stderr) - - def find_in_file(self, filename: str | os.PathLike, regex) -> re.Match | None: - """Find first pattern occurrence in file""" - pattern = re.compile(regex) - with Path(filename).open("r", encoding="utf-8") as f: - for line in f: - match = pattern.search(line) - if match is not None: - return match - return None + @pytest.fixture + def proj_path(self, tmp_path: Path, _proj_path_cached: Path) -> Path: + """Copy a pre-generated Scrapy project into a temporary directory and return its path.""" + proj_path = tmp_path / self.project_name + copytree(_proj_path_cached, proj_path) + return proj_path -class TestCommandBase(TestProjectBase): - def setup_method(self): - super().setup_method() - self.call("startproject", self.project_name) - self.cwd = self.proj_path - self.env["SCRAPY_SETTINGS_MODULE"] = f"{self.project_name}.settings" - - -class TestCommandCrawlerProcess(TestCommandBase): +class TestCommandCrawlerProcess(TestProjectBase): """Test that the command uses the expected kind of *CrawlerProcess and produces expected errors when needed.""" - name = "crawltest" + name = "crawl" + NORMAL_MSG = "Using CrawlerProcess" + ASYNC_MSG = "Using AsyncCrawlerProcess" - NORMAL_MSG = "Type of self.crawler_process: " - ASYNC_MSG = ( - "Type of self.crawler_process: " - ) - - def setup_method(self): - super().setup_method() - (self.cwd / self.project_name / "commands").mkdir(exist_ok=True) - (self.cwd / self.project_name / "commands" / "__init__.py").touch() - (self.cwd / self.project_name / "commands" / f"{self.name}.py").write_text(""" -from scrapy.commands.crawl import Command - -class CrawlerProcessCrawlCommand(Command): - requires_project = True - - def run(self, args, opts): - print(f"Type of self.crawler_process: {type(self.crawler_process)}") - super().run(args, opts) -""") - - self._append_settings(f"COMMANDS_MODULE = '{self.project_name}.commands'\n") - - (self.cwd / self.project_name / "spiders" / "sp.py").write_text(""" + @pytest.fixture(autouse=True) + def create_files(self, proj_path: Path) -> None: + proj_mod_path = proj_path / self.project_name + (proj_mod_path / "spiders" / "sp.py").write_text(""" import scrapy class MySpider(scrapy.Spider): @@ -167,7 +110,7 @@ class MySpider(scrapy.Spider): yield """) - (self.cwd / self.project_name / "spiders" / "aiosp.py").write_text(""" + (proj_mod_path / "spiders" / "aiosp.py").write_text(""" import asyncio import scrapy @@ -184,16 +127,20 @@ class MySpider(scrapy.Spider): yield """) - def _append_settings(self, text: str) -> None: + 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 (self.cwd / self.project_name / "settings.py").open( - "a", encoding="utf-8" - ) as f: + with (proj_mod_path / "settings.py").open("a", encoding="utf-8") as f: f.write(text) - def _replace_custom_settings(self, spider_name: str, text: str) -> None: + @staticmethod + def _replace_custom_settings( + proj_mod_path: Path, spider_name: str, text: str + ) -> None: """Replace custom_settings in the given spider file with the given text.""" - spider_path = self.cwd / self.project_name / "spiders" / f"{spider_name}.py" + spider_path = proj_mod_path / "spiders" / f"{spider_name}.py" with spider_path.open("r+", encoding="utf-8") as f: content = f.read() content = content.replace( @@ -203,74 +150,87 @@ class MySpider(scrapy.Spider): f.write(content) f.truncate() - def _assert_spider_works(self, msg: str, *args: str) -> None: + def _assert_spider_works(self, msg: str, proj_path: Path, *args: str) -> None: """The command uses the expected *CrawlerProcess, the spider works.""" - _, out, err = self.proc(self.name, *args) - assert msg in out, out - assert "It works!" in err, err - assert "Spider closed (finished)" in err, err + _, _, err = proc(self.name, *args, cwd=proj_path) + assert msg in err + assert "It works!" in err + assert "Spider closed (finished)" in err - def _assert_spider_asyncio_fail(self, msg: str, *args: str) -> None: + def _assert_spider_asyncio_fail( + self, msg: str, proj_path: Path, *args: str + ) -> None: """The command uses the expected *CrawlerProcess, the spider fails to use asyncio.""" - _, out, err = self.proc(self.name, *args) - assert msg in out, out - assert "no running event loop" in err, err + _, _, err = proc(self.name, *args, cwd=proj_path) + assert msg in err + assert "no running event loop" in err - def test_project_settings(self): + def test_project_settings(self, proj_path: Path) -> None: """The reactor is set via the project default settings (to the asyncio value). AsyncCrawlerProcess, the asyncio reactor, both spiders work.""" for spider in ["sp", "aiosp"]: - self._assert_spider_works(self.ASYNC_MSG, spider) + self._assert_spider_works(self.ASYNC_MSG, proj_path, spider) - def test_cmdline_asyncio(self): + def test_cmdline_asyncio(self, proj_path: Path) -> None: """The reactor is set via the command line to the asyncio value. AsyncCrawlerProcess, the asyncio reactor, both spiders work.""" for spider in ["sp", "aiosp"]: self._assert_spider_works( - self.ASYNC_MSG, spider, "-s", f"TWISTED_REACTOR={_asyncio_reactor_path}" + self.ASYNC_MSG, + proj_path, + spider, + "-s", + f"TWISTED_REACTOR={_asyncio_reactor_path}", ) - def test_project_settings_explicit_asyncio(self): + def test_project_settings_explicit_asyncio(self, proj_path: Path) -> None: """The reactor explicitly is set via the project settings to the asyncio value. AsyncCrawlerProcess, the asyncio reactor, both spiders work.""" - self._append_settings(f"TWISTED_REACTOR = '{_asyncio_reactor_path}'\n") + self._append_settings( + proj_path / self.project_name, + f"TWISTED_REACTOR = '{_asyncio_reactor_path}'\n", + ) for spider in ["sp", "aiosp"]: - self._assert_spider_works(self.ASYNC_MSG, spider) + self._assert_spider_works(self.ASYNC_MSG, proj_path, spider) - def test_cmdline_empty(self): + def test_cmdline_empty(self, proj_path: Path) -> None: """The reactor is set via the command line to the empty value. CrawlerProcess, the default reactor, only the normal spider works.""" - self._assert_spider_works(self.NORMAL_MSG, "sp", "-s", "TWISTED_REACTOR=") + self._assert_spider_works( + self.NORMAL_MSG, proj_path, "sp", "-s", "TWISTED_REACTOR=" + ) self._assert_spider_asyncio_fail( - self.NORMAL_MSG, "aiosp", "-s", "TWISTED_REACTOR=" + self.NORMAL_MSG, proj_path, "aiosp", "-s", "TWISTED_REACTOR=" ) - def test_project_settings_empty(self): + def test_project_settings_empty(self, proj_path: Path) -> None: """The reactor is set via the project settings to the empty value. CrawlerProcess, the default reactor, only the normal spider works.""" - self._append_settings("TWISTED_REACTOR = None\n") + self._append_settings(proj_path / self.project_name, "TWISTED_REACTOR = None\n") - self._assert_spider_works(self.NORMAL_MSG, "sp") + self._assert_spider_works(self.NORMAL_MSG, proj_path, "sp") self._assert_spider_asyncio_fail( - self.NORMAL_MSG, "aiosp", "-s", "TWISTED_REACTOR=" + self.NORMAL_MSG, proj_path, "aiosp", "-s", "TWISTED_REACTOR=" ) - def test_spider_settings_asyncio(self): + def test_spider_settings_asyncio(self, proj_path: Path) -> None: """The reactor is set via the spider settings to the asyncio value. AsyncCrawlerProcess, the asyncio reactor, both spiders work.""" for spider in ["sp", "aiosp"]: self._replace_custom_settings( - spider, f"{{'TWISTED_REACTOR': '{_asyncio_reactor_path}'}}" + proj_path / self.project_name, + spider, + f"{{'TWISTED_REACTOR': '{_asyncio_reactor_path}'}}", ) - self._assert_spider_works(self.ASYNC_MSG, spider) + self._assert_spider_works(self.ASYNC_MSG, proj_path, spider) - def test_spider_settings_asyncio_cmdline_empty(self): + def test_spider_settings_asyncio_cmdline_empty(self, proj_path: Path) -> None: """The reactor is set via the spider settings to the asyncio value and via command line to the empty value. The command line value takes precedence so the spider settings don't matter. @@ -278,29 +238,35 @@ class MySpider(scrapy.Spider): CrawlerProcess, the default reactor, only the normal spider works.""" for spider in ["sp", "aiosp"]: self._replace_custom_settings( - spider, f"{{'TWISTED_REACTOR': '{_asyncio_reactor_path}'}}" + proj_path / self.project_name, + spider, + f"{{'TWISTED_REACTOR': '{_asyncio_reactor_path}'}}", ) - self._assert_spider_works(self.NORMAL_MSG, "sp", "-s", "TWISTED_REACTOR=") + self._assert_spider_works( + self.NORMAL_MSG, proj_path, "sp", "-s", "TWISTED_REACTOR=" + ) self._assert_spider_asyncio_fail( - self.NORMAL_MSG, "aiosp", "-s", "TWISTED_REACTOR=" + self.NORMAL_MSG, proj_path, "aiosp", "-s", "TWISTED_REACTOR=" ) - def test_project_empty_spider_settings_asyncio(self): + def test_project_empty_spider_settings_asyncio(self, proj_path: Path) -> None: """The reactor is set via the project settings to the empty value and via the spider settings to the asyncio value. CrawlerProcess is chosen based on the project settings, but the asyncio reactor is chosen based on the spider settings. CrawlerProcess, the asyncio reactor, both spiders work.""" - self._append_settings("TWISTED_REACTOR = None\n") + self._append_settings(proj_path / self.project_name, "TWISTED_REACTOR = None\n") for spider in ["sp", "aiosp"]: self._replace_custom_settings( - spider, f"{{'TWISTED_REACTOR': '{_asyncio_reactor_path}'}}" + proj_path / self.project_name, + spider, + f"{{'TWISTED_REACTOR': '{_asyncio_reactor_path}'}}", ) - self._assert_spider_works(self.NORMAL_MSG, spider) + self._assert_spider_works(self.NORMAL_MSG, proj_path, spider) - def test_project_asyncio_spider_settings_select(self): + def test_project_asyncio_spider_settings_select(self, proj_path: Path) -> None: """The reactor is set via the project settings to the asyncio value and via the spider settings to the select value. AsyncCrawlerProcess is chosen based on the project settings, and the conflicting reactor @@ -308,43 +274,58 @@ class MySpider(scrapy.Spider): AsyncCrawlerProcess, the asyncio reactor, both spiders produce a mismatched reactor exception.""" - self._append_settings(f"TWISTED_REACTOR = '{_asyncio_reactor_path}'\n") + self._append_settings( + proj_path / self.project_name, + f"TWISTED_REACTOR = '{_asyncio_reactor_path}'\n", + ) for spider in ["sp", "aiosp"]: self._replace_custom_settings( + proj_path / self.project_name, spider, "{'TWISTED_REACTOR': 'twisted.internet.selectreactor.SelectReactor'}", ) - _, out, err = self.proc(self.name, spider) - assert self.ASYNC_MSG in out, out + _, _, err = proc(self.name, spider, cwd=proj_path) + assert self.ASYNC_MSG in err assert ( "The installed reactor (twisted.internet.asyncioreactor.AsyncioSelectorReactor)" " does not match the requested one" " (twisted.internet.selectreactor.SelectReactor)" - ) in err, err + ) in err - def test_project_asyncio_spider_settings_select_forced(self): + def test_project_asyncio_spider_settings_select_forced( + self, proj_path: Path + ) -> None: """The reactor is set via the project settings to the asyncio value and via the spider settings to the select value, CrawlerProcess is forced via the project settings. The reactor is chosen based on the spider settings. CrawlerProcess, the select reactor, only the normal spider works.""" - self._append_settings("FORCE_CRAWLER_PROCESS = True\n") + self._append_settings( + proj_path / self.project_name, "FORCE_CRAWLER_PROCESS = True\n" + ) for spider in ["sp", "aiosp"]: self._replace_custom_settings( + proj_path / self.project_name, spider, "{'TWISTED_REACTOR': 'twisted.internet.selectreactor.SelectReactor'}", ) - self._assert_spider_works(self.NORMAL_MSG, "sp") - self._assert_spider_asyncio_fail(self.NORMAL_MSG, "aiosp") + self._assert_spider_works(self.NORMAL_MSG, proj_path, "sp") + self._assert_spider_asyncio_fail(self.NORMAL_MSG, proj_path, "aiosp") -class TestMiscCommands(TestCommandBase): - def test_list(self): - assert self.call("list") == 0 +class TestMiscCommands(TestProjectBase): + def test_list(self, proj_path: Path) -> None: + assert call("list", cwd=proj_path) == 0 - def test_command_not_found(self): + def test_list_subdir(self, proj_path: Path) -> None: + """Test that commands work in a subdirectory of the project.""" + subdir = proj_path / "subdir" + 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. @@ -353,9 +334,9 @@ These commands are only available from within a project: check, crawl, edit, lis Unknown command: abc """ params = [ - ("list", 0, na_msg), - ("abc", 0, not_found_msg), - ("abc", 1, not_found_msg), + ("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: @@ -363,31 +344,22 @@ Unknown command: abc assert out.getvalue().strip() == message.strip() -class TestProjectSubdir(TestProjectBase): - """Test that commands work in a subdirectory of the project.""" - - def setup_method(self): - super().setup_method() - self.call("startproject", self.project_name) - self.cwd = self.proj_path / "subdir" - self.cwd.mkdir(exist_ok=True) - - def test_list(self): - assert self.call("list") == 0 - - -class TestBenchCommand(TestCommandBase): - def test_run(self): - _, _, log = self.proc( - "bench", "-s", "LOGSTATS_INTERVAL=0.001", "-s", "CLOSESPIDER_TIMEOUT=0.01" +class TestBenchCommand: + def test_run(self) -> None: + _, _, err = proc( + "bench", + "-s", + "LOGSTATS_INTERVAL=0.001", + "-s", + "CLOSESPIDER_TIMEOUT=0.01", ) - assert "INFO: Crawled" in log - assert "Unhandled Error" not in log - assert "log_count/ERROR" not in log + assert "INFO: Crawled" in err + assert "Unhandled Error" not in err + assert "log_count/ERROR" not in err -class TestViewCommand(TestCommandBase): - def test_methods(self): +class TestViewCommand: + def test_methods(self) -> None: command = view.Command() command.settings = Settings() parser = argparse.ArgumentParser( @@ -401,52 +373,50 @@ class TestViewCommand(TestCommandBase): assert "URL using the Scrapy downloader and show its" in command.long_desc() -class TestHelpMessage(TestCommandBase): - def setup_method(self): - super().setup_method() - self.commands = [ - "parse", - "startproject", - "view", - "crawl", - "edit", - "list", - "fetch", - "settings", - "shell", - "runspider", - "version", - "genspider", - "check", - "bench", - ] +class TestHelpMessage(TestProjectBase): + COMMANDS = [ + "parse", + "startproject", + "view", + "crawl", + "edit", + "list", + "fetch", + "settings", + "shell", + "runspider", + "version", + "genspider", + "check", + "bench", + ] - def test_help_messages(self): - for command in self.commands: - _, out, _ = self.proc(command, "-h") + def test_help_messages(self, proj_path: Path) -> None: + for command in self.COMMANDS: + _, out, _ = proc(command, "-h", cwd=proj_path) assert "Usage" in out class TestPopCommandName: - def test_valid_command(self): + def test_valid_command(self) -> None: argv = ["scrapy", "crawl", "my_spider"] command = _pop_command_name(argv) assert command == "crawl" assert argv == ["scrapy", "my_spider"] - def test_no_command(self): + def test_no_command(self) -> None: argv = ["scrapy"] command = _pop_command_name(argv) assert command is None assert argv == ["scrapy"] - def test_option_before_command(self): + def test_option_before_command(self) -> None: argv = ["scrapy", "-h", "crawl"] command = _pop_command_name(argv) assert command == "crawl" assert argv == ["scrapy", "-h"] - def test_option_after_command(self): + def test_option_after_command(self) -> None: argv = ["scrapy", "crawl", "-h"] command = _pop_command_name(argv) assert command == "crawl" diff --git a/tests/test_contracts.py b/tests/test_contracts.py index ad3efa042..326be570e 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -3,7 +3,6 @@ from unittest import TextTestResult import pytest from twisted.internet.defer import inlineCallbacks from twisted.python import failure -from twisted.trial import unittest from scrapy import FormRequest from scrapy.contracts import Contract, ContractsManager @@ -19,7 +18,7 @@ from scrapy.item import Field, Item from scrapy.spidermiddlewares.httperror import HttpError from scrapy.spiders import Spider from scrapy.utils.test import get_crawler -from tests.mockserver import MockServer +from tests.mockserver.http import MockServer class DemoItem(Item): @@ -247,7 +246,7 @@ class InheritsDemoSpider(DemoSpider): name = "inherits_demo_spider" -class TestContractsManager(unittest.TestCase): +class TestContractsManager: contracts = [ UrlContract, CallbackKeywordArgumentsContract, @@ -259,7 +258,7 @@ class TestContractsManager(unittest.TestCase): CustomFailContract, ] - def setUp(self): + def setup_method(self): self.conman = ContractsManager(self.contracts) self.results = TextTestResult(stream=None, descriptions=False, verbosity=0) diff --git a/tests/test_core_downloader.py b/tests/test_core_downloader.py index 464320602..a84bb9d8d 100644 --- a/tests/test_core_downloader.py +++ b/tests/test_core_downloader.py @@ -1,33 +1,34 @@ from __future__ import annotations -import shutil import warnings -from pathlib import Path -from tempfile import mkdtemp -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast import OpenSSL.SSL import pytest -from twisted.internet.defer import Deferred, inlineCallbacks -from twisted.protocols.policies import WrappingFactory -from twisted.trial import unittest +from pytest_twisted import async_yield_fixture from twisted.web import server, static from twisted.web.client import Agent, BrowserLikePolicyForHTTPS, readBody from twisted.web.client import Response as TxResponse -from twisted.web.iweb import IBodyProducer -from scrapy.core.downloader import Slot +from scrapy.core.downloader import Downloader, Slot from scrapy.core.downloader.contextfactory import ( ScrapyClientContextFactory, load_context_factory_from_settings, ) from scrapy.core.downloader.handlers.http11 import _RequestBodyProducer +from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.settings import Settings from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future from scrapy.utils.misc import build_from_crawler from scrapy.utils.python import to_bytes +from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler -from tests.mockserver import PayloadResource, ssl_context_factory +from tests.mockserver.http_resources import PayloadResource +from tests.mockserver.utils import ssl_context_factory + +if TYPE_CHECKING: + from twisted.internet.defer import Deferred + from twisted.web.iweb import IBodyProducer class TestSlot: @@ -36,9 +37,22 @@ class TestSlot: assert repr(slot) == "Slot(concurrency=8, delay=0.10, randomize_delay=True)" -class TestContextFactoryBase(unittest.TestCase): +class TestContextFactoryBase: context_factory = None + @async_yield_fixture + async def server_url(self, tmp_path): + (tmp_path / "file").write_bytes(b"0123456789") + r = static.File(str(tmp_path)) + r.putChild(b"payload", PayloadResource()) + site = server.Site(r, timeout=None) + port = self._listen(site) + portno = port.getHost().port + + yield f"https://127.0.0.1:{portno}/" + + await port.stopListening() + def _listen(self, site): from twisted.internet import reactor @@ -49,24 +63,6 @@ class TestContextFactoryBase(unittest.TestCase): interface="127.0.0.1", ) - def getURL(self, path): - return f"https://127.0.0.1:{self.portno}/{path}" - - def setUp(self): - self.tmpname = Path(mkdtemp()) - (self.tmpname / "file").write_bytes(b"0123456789") - r = static.File(str(self.tmpname)) - r.putChild(b"payload", PayloadResource()) - self.site = server.Site(r, timeout=None) - self.wrapper = WrappingFactory(self.site) - self.port = self._listen(self.wrapper) - self.portno = self.port.getHost().port - - @inlineCallbacks - def tearDown(self): - yield self.port.stopListening() - shutil.rmtree(self.tmpname) - @staticmethod async def get_page( url: str, @@ -78,12 +74,12 @@ class TestContextFactoryBase(unittest.TestCase): agent = Agent(reactor, contextFactory=client_context_factory) body_producer = _RequestBodyProducer(body.encode()) if body else None response: TxResponse = cast( - TxResponse, + "TxResponse", await maybe_deferred_to_future( agent.request( b"GET", url.encode(), - bodyProducer=cast(IBodyProducer, body_producer), + bodyProducer=cast("IBodyProducer", body_producer), ) ), ) @@ -100,13 +96,13 @@ class TestContextFactoryBase(unittest.TestCase): class TestContextFactory(TestContextFactoryBase): @deferred_f_from_coro_f - async def testPayload(self): + async def testPayload(self, server_url: str) -> None: s = "0123456789" * 10 crawler = get_crawler() settings = Settings() client_context_factory = load_context_factory_from_settings(settings, crawler) body = await self.get_page( - self.getURL("payload"), client_context_factory, body=s + server_url + "payload", client_context_factory, body=s ) assert body == to_bytes(s) @@ -129,21 +125,21 @@ class TestContextFactory(TestContextFactoryBase): class TestContextFactoryTLSMethod(TestContextFactoryBase): async def _assert_factory_works( - self, client_context_factory: ScrapyClientContextFactory + self, server_url: str, client_context_factory: ScrapyClientContextFactory ) -> None: s = "0123456789" * 10 body = await self.get_page( - self.getURL("payload"), client_context_factory, body=s + server_url + "payload", client_context_factory, body=s ) assert body == to_bytes(s) @deferred_f_from_coro_f - async def test_setting_default(self): + async def test_setting_default(self, server_url: str) -> None: crawler = get_crawler() settings = Settings() client_context_factory = load_context_factory_from_settings(settings, crawler) assert client_context_factory._ssl_method == OpenSSL.SSL.SSLv23_METHOD - await self._assert_factory_works(client_context_factory) + await self._assert_factory_works(server_url, client_context_factory) def test_setting_none(self): crawler = get_crawler() @@ -158,23 +154,37 @@ class TestContextFactoryTLSMethod(TestContextFactoryBase): load_context_factory_from_settings(settings, crawler) @deferred_f_from_coro_f - async def test_setting_explicit(self): + async def test_setting_explicit(self, server_url: str) -> None: crawler = get_crawler() settings = Settings({"DOWNLOADER_CLIENT_TLS_METHOD": "TLSv1.2"}) client_context_factory = load_context_factory_from_settings(settings, crawler) assert client_context_factory._ssl_method == OpenSSL.SSL.TLSv1_2_METHOD - await self._assert_factory_works(client_context_factory) + await self._assert_factory_works(server_url, client_context_factory) @deferred_f_from_coro_f - async def test_direct_from_crawler(self): + async def test_direct_from_crawler(self, server_url: str) -> None: # the setting is ignored crawler = get_crawler(settings_dict={"DOWNLOADER_CLIENT_TLS_METHOD": "bad"}) client_context_factory = build_from_crawler(ScrapyClientContextFactory, crawler) assert client_context_factory._ssl_method == OpenSSL.SSL.SSLv23_METHOD - await self._assert_factory_works(client_context_factory) + await self._assert_factory_works(server_url, client_context_factory) @deferred_f_from_coro_f - async def test_direct_init(self): + async def test_direct_init(self, server_url: str) -> None: client_context_factory = ScrapyClientContextFactory(OpenSSL.SSL.TLSv1_2_METHOD) assert client_context_factory._ssl_method == OpenSSL.SSL.TLSv1_2_METHOD - await self._assert_factory_works(client_context_factory) + await self._assert_factory_works(server_url, client_context_factory) + + +@deferred_f_from_coro_f +async def test_fetch_deprecated_spider_arg(): + class CustomDownloader(Downloader): + def fetch(self, request, spider): # pylint: disable=signature-differs + return super().fetch(request, spider) + + crawler = get_crawler(DefaultSpider, {"DOWNLOADER": CustomDownloader}) + with pytest.warns( + ScrapyDeprecationWarning, + match=r"The fetch\(\) method of .+\.CustomDownloader requires a spider argument", + ): + await maybe_deferred_to_future(crawler.crawl()) diff --git a/tests/test_core_scraper.py b/tests/test_core_scraper.py new file mode 100644 index 000000000..c819e246e --- /dev/null +++ b/tests/test_core_scraper.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future +from scrapy.utils.test import get_crawler +from tests.spiders import SimpleSpider + +if TYPE_CHECKING: + import pytest + + from tests.mockserver.http import MockServer + + +@deferred_f_from_coro_f +async def test_scraper_exception( + mockserver: MockServer, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + crawler = get_crawler(SimpleSpider) + monkeypatch.setattr( + "scrapy.core.engine.Scraper.handle_spider_output_async", + lambda *args, **kwargs: 1 / 0, + ) + await maybe_deferred_to_future(crawler.crawl(url=mockserver.url("/"))) + assert "Scraper bug processing" in caplog.text diff --git a/tests/test_crawl.py b/tests/test_crawl.py index 42f9899f9..dc85d9d68 100644 --- a/tests/test_crawl.py +++ b/tests/test_crawl.py @@ -5,14 +5,13 @@ import logging from ipaddress import IPv4Address from socket import gethostbyname from typing import TYPE_CHECKING, Any -from urllib.parse import urlparse +from urllib.parse import urlencode, urlparse import pytest from testfixtures import LogCapture from twisted.internet.defer import inlineCallbacks from twisted.internet.ssl import Certificate from twisted.python.failure import Failure -from twisted.trial.unittest import TestCase from scrapy import Spider, signals from scrapy.crawler import CrawlerRunner @@ -20,10 +19,11 @@ from scrapy.exceptions import CloseSpider, StopDownload from scrapy.http import Request from scrapy.http.response import Response from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future +from scrapy.utils.engine import format_engine_status, get_engine_status from scrapy.utils.python import to_unicode from scrapy.utils.test import get_crawler, get_reactor_settings from tests import NON_EXISTING_RESOLVABLE -from tests.mockserver import MockServer +from tests.mockserver.http import MockServer from tests.spiders import ( AsyncDefAsyncioGenComplexSpider, AsyncDefAsyncioGenExcSpider, @@ -60,16 +60,16 @@ if TYPE_CHECKING: from scrapy.statscollectors import StatsCollector -class TestCrawl(TestCase): +class TestCrawl: mockserver: MockServer @classmethod - def setUpClass(cls): + def setup_class(cls): cls.mockserver = MockServer() cls.mockserver.__enter__() @classmethod - def tearDownClass(cls): + def teardown_class(cls): cls.mockserver.__exit__(None, None, None) @inlineCallbacks @@ -255,8 +255,6 @@ class TestCrawl(TestCase): def test_unbounded_response(self): # Completeness of responses without Content-Length or Transfer-Encoding # can not be determined, we treat them as valid but flagged as "partial" - from urllib.parse import urlencode - query = urlencode( { "raw": """\ @@ -339,8 +337,6 @@ with multiples lines @inlineCallbacks def test_engine_status(self): - from scrapy.utils.engine import get_engine_status - est = [] def cb(response): @@ -357,8 +353,6 @@ with multiples lines @inlineCallbacks def test_format_engine_status(self): - from scrapy.utils.engine import format_engine_status - est = [] def cb(response): @@ -375,7 +369,7 @@ with multiples lines est = [x for sublist in est for x in sublist] # flatten est = [x.lstrip().rstrip() for x in est] it = iter(est) - s = dict(zip(it, it)) + s = dict(zip(it, it, strict=False)) assert s["engine.spider.name"] == crawler.spider.name assert s["len(engine.scraper.slot.active)"] == "1" @@ -407,7 +401,7 @@ with multiples lines assert "Got response 200" in str(log) @inlineCallbacks - def test_crawl_multiple(self): + def test_crawl_multiple(self, caplog: pytest.LogCaptureFixture): runner = CrawlerRunner(get_reactor_settings()) runner.crawl( SimpleSpider, @@ -420,23 +414,23 @@ with multiples lines mockserver=self.mockserver, ) - with LogCapture() as log: + with caplog.at_level(logging.DEBUG): yield runner.join() - self._assert_retried(log) - assert "Got response 200" in str(log) + self._assert_retried(caplog.text) + assert "Got response 200" in caplog.text -class TestCrawlSpider(TestCase): +class TestCrawlSpider: mockserver: MockServer @classmethod - def setUpClass(cls): + def setup_class(cls): cls.mockserver = MockServer() cls.mockserver.__enter__() @classmethod - def tearDownClass(cls): + def teardown_class(cls): cls.mockserver.__exit__(None, None, None) async def _run_spider( @@ -618,7 +612,7 @@ class TestCrawlSpider(TestCase): @pytest.mark.only_asyncio @deferred_f_from_coro_f async def test_async_def_deferred_wrapped(self): - log, items, _ = await self._run_spider(AsyncDefDeferredWrappedSpider) + _, items, _ = await self._run_spider(AsyncDefDeferredWrappedSpider) assert items == [{"code": 200}] @deferred_f_from_coro_f diff --git a/tests/test_crawler.py b/tests/test_crawler.py index 56cb21650..4f1045786 100644 --- a/tests/test_crawler.py +++ b/tests/test_crawler.py @@ -7,6 +7,7 @@ import subprocess import sys import warnings from abc import ABC, abstractmethod +from collections.abc import Generator from pathlib import Path from typing import Any @@ -14,7 +15,6 @@ import pytest from packaging.version import parse as parse_version from pexpect.popen_spawn import PopenSpawn from twisted.internet.defer import Deferred, inlineCallbacks -from twisted.trial import unittest from w3lib import __version__ as w3lib_version from zope.interface.exceptions import MultipleInvalid @@ -30,11 +30,21 @@ from scrapy.crawler import ( from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.extensions.throttle import AutoThrottle from scrapy.settings import Settings, default_settings -from scrapy.utils.defer import deferred_f_from_coro_f, deferred_from_coro -from scrapy.utils.log import configure_logging, get_scrapy_root_handler +from scrapy.utils.asyncio import call_later +from scrapy.utils.defer import ( + deferred_f_from_coro_f, + deferred_from_coro, + maybe_deferred_to_future, +) +from scrapy.utils.log import ( + _uninstall_scrapy_root_handler, + configure_logging, + get_scrapy_root_handler, +) from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler, get_reactor_settings -from tests.mockserver import MockServer, get_mockserver_env +from tests.mockserver.http import MockServer +from tests.utils import get_script_run_env BASE_SETTINGS: dict[str, Any] = {} @@ -48,7 +58,7 @@ def get_raw_crawler(spidercls=None, settings_dict=None): return Crawler(spidercls or DefaultSpider, settings) -class TestBaseCrawler(unittest.TestCase): +class TestBaseCrawler: def assertOptionIsDefault(self, settings, key): assert isinstance(settings, Settings) assert settings[key] == getattr(default_settings, key) @@ -510,10 +520,13 @@ class TestCrawlerLogging: get_crawler(MySpider) assert get_scrapy_root_handler() is None - def test_spider_custom_settings_log_level(self, tmp_path): + @deferred_f_from_coro_f + async def test_spider_custom_settings_log_level(self, tmp_path): log_file = Path(tmp_path, "log.txt") log_file.write_text("previous message\n", encoding="utf-8") + info_count = None + class MySpider(scrapy.Spider): name = "spider" custom_settings = { @@ -521,15 +534,27 @@ class TestCrawlerLogging: "LOG_FILE": str(log_file), } - configure_logging() - assert get_scrapy_root_handler().level == logging.DEBUG - crawler = get_crawler(MySpider) - assert get_scrapy_root_handler().level == logging.INFO - info_count = crawler.stats.get_value("log_count/INFO") - logging.debug("debug message") - logging.info("info message") - logging.warning("warning message") - logging.error("error message") + async def start(self): + info_count_start = crawler.stats.get_value("log_count/INFO") + logging.debug("debug message") # noqa: LOG015 + logging.info("info message") # noqa: LOG015 + logging.warning("warning message") # noqa: LOG015 + logging.error("error message") # noqa: LOG015 + nonlocal info_count + info_count = ( + crawler.stats.get_value("log_count/INFO") - info_count_start + ) + return + yield + + try: + configure_logging() + assert get_scrapy_root_handler().level == logging.DEBUG + crawler = get_crawler(MySpider) + assert get_scrapy_root_handler().level == logging.INFO + await maybe_deferred_to_future(crawler.crawl()) + finally: + _uninstall_scrapy_root_handler() logged = log_file.read_text(encoding="utf-8") @@ -540,7 +565,7 @@ class TestCrawlerLogging: assert "error message" in logged assert crawler.stats.get_value("log_count/ERROR") == 1 assert crawler.stats.get_value("log_count/WARNING") == 1 - assert crawler.stats.get_value("log_count/INFO") - info_count == 1 + assert info_count == 1 assert crawler.stats.get_value("log_count/DEBUG", 0) == 0 def test_spider_custom_settings_log_append(self, tmp_path): @@ -554,9 +579,12 @@ class TestCrawlerLogging: "LOG_FILE_APPEND": False, } - configure_logging() - get_crawler(MySpider) - logging.debug("debug message") + try: + configure_logging() + get_crawler(MySpider) + logging.debug("debug message") # noqa: LOG015 + finally: + _uninstall_scrapy_root_handler() logged = log_file.read_text(encoding="utf-8") @@ -611,24 +639,24 @@ class TestAsyncCrawlerRunner(TestBaseCrawler): class TestCrawlerProcess(TestBaseCrawler): def test_crawler_process_accepts_dict(self): - runner = CrawlerProcess({"foo": "bar"}) + runner = CrawlerProcess({"foo": "bar"}, install_root_handler=False) assert runner.settings["foo"] == "bar" self.assertOptionIsDefault(runner.settings, "RETRY_ENABLED") def test_crawler_process_accepts_None(self): - runner = CrawlerProcess() + runner = CrawlerProcess(install_root_handler=False) self.assertOptionIsDefault(runner.settings, "RETRY_ENABLED") @pytest.mark.only_asyncio class TestAsyncCrawlerProcess(TestBaseCrawler): def test_crawler_process_accepts_dict(self): - runner = AsyncCrawlerProcess({"foo": "bar"}) + runner = AsyncCrawlerProcess({"foo": "bar"}, install_root_handler=False) assert runner.settings["foo"] == "bar" self.assertOptionIsDefault(runner.settings, "RETRY_ENABLED") def test_crawler_process_accepts_None(self): - runner = AsyncCrawlerProcess() + runner = AsyncCrawlerProcess(install_root_handler=False) self.assertOptionIsDefault(runner.settings, "RETRY_ENABLED") @@ -648,8 +676,7 @@ class NoRequestsSpider(scrapy.Spider): yield -@pytest.mark.usefixtures("reactor_pytest") -class TestCrawlerRunnerHasSpider(unittest.TestCase): +class TestCrawlerRunnerHasSpider: @staticmethod def _runner(): return CrawlerRunner(get_reactor_settings()) @@ -700,8 +727,10 @@ class TestCrawlerRunnerHasSpider(unittest.TestCase): assert runner.bootstrap_failed @inlineCallbacks - def test_crawler_runner_asyncio_enabled_true(self): - if self.reactor_pytest == "default": + def test_crawler_runner_asyncio_enabled_true( + self, reactor_pytest: str + ) -> Generator[Deferred[Any], Any, None]: + if reactor_pytest != "asyncio": runner = CrawlerRunner( settings={ "TWISTED_REACTOR": "twisted.internet.asyncioreactor.AsyncioSelectorReactor", @@ -752,15 +781,15 @@ class ScriptRunnerMixin(ABC): args = self.get_script_args(script_name, *script_args) p = subprocess.Popen( args, - env=get_mockserver_env(), + env=get_script_run_env(), stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) - stdout, stderr = p.communicate() + _, stderr = p.communicate() return stderr.decode("utf-8") -class TestCrawlerProcessSubprocessBase(ScriptRunnerMixin, unittest.TestCase): +class TestCrawlerProcessSubprocessBase(ScriptRunnerMixin): """Common tests between CrawlerProcess and AsyncCrawlerProcess, with the same file names and expectations. """ @@ -829,14 +858,14 @@ class TestCrawlerProcessSubprocessBase(ScriptRunnerMixin, unittest.TestCase): assert "Spider closed (finished)" in log assert "twisted.internet.error.DNSLookupError" not in log - def test_caching_hostname_resolver_finite_execution(self): - with MockServer() as mock_server: - http_address = mock_server.http_address.replace("0.0.0.0", "127.0.0.1") - log = self.run_script("caching_hostname_resolver.py", http_address) - assert "Spider closed (finished)" in log - assert "ERROR: Error downloading" not in log - assert "TimeoutError" not in log - assert "twisted.internet.error.DNSLookupError" not in log + def test_caching_hostname_resolver_finite_execution( + self, mockserver: MockServer + ) -> None: + log = self.run_script("caching_hostname_resolver.py", mockserver.url("/")) + assert "Spider closed (finished)" in log + assert "ERROR: Error downloading" not in log + assert "TimeoutError" not in log + assert "twisted.internet.error.DNSLookupError" not in log def test_twisted_reactor_asyncio(self): log = self.run_script("twisted_reactor_asyncio.py") @@ -930,8 +959,6 @@ class TestCrawlerProcessSubprocessBase(ScriptRunnerMixin, unittest.TestCase): @inlineCallbacks def test_shutdown_forced(self): - from twisted.internet import reactor - sig = signal.SIGINT if sys.platform != "win32" else signal.SIGBREAK args = self.get_script_args("sleeping.py", "10") p = PopenSpawn(args, timeout=5) @@ -941,7 +968,7 @@ class TestCrawlerProcessSubprocessBase(ScriptRunnerMixin, unittest.TestCase): p.expect_exact("shutting down gracefully") # sending the second signal too fast often causes problems d = Deferred() - reactor.callLater(0.01, d.callback, None) + call_later(0.01, d.callback, None) yield d p.kill(sig) p.expect_exact("forcing unclean shutdown") @@ -1160,7 +1187,7 @@ class TestAsyncCrawlerRunnerSubprocess(TestCrawlerRunnerSubprocessBase): ) def test_log_scrapy_info(settings, items, caplog): with caplog.at_level("INFO"): - CrawlerProcess(settings) + CrawlerProcess(settings, install_root_handler=False) assert ( caplog.records[0].getMessage() == f"Scrapy {scrapy.__version__} started (bot: scrapybot)" @@ -1173,3 +1200,14 @@ def test_log_scrapy_info(settings, items, caplog): f"{item}': '[^']+('\n +'[^']+)*" for item in items ) assert re.search(r"^Versions:\n{'" + expected_items_pattern + "'}$", version_string) + + +@deferred_f_from_coro_f +async def test_deprecated_crawler_stop() -> None: + crawler = get_crawler(DefaultSpider) + d = crawler.crawl() + await maybe_deferred_to_future(d) + with pytest.warns( + ScrapyDeprecationWarning, match=r"Crawler.stop\(\) is deprecated" + ): + await maybe_deferred_to_future(crawler.stop()) diff --git a/tests/test_downloader_handler_twisted_ftp.py b/tests/test_downloader_handler_twisted_ftp.py new file mode 100644 index 000000000..a43fe318d --- /dev/null +++ b/tests/test_downloader_handler_twisted_ftp.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +import os +import sys +from pathlib import Path +from tempfile import mkstemp +from typing import TYPE_CHECKING, Any + +import pytest +from pytest_twisted import async_yield_fixture +from twisted.cred import checkers, credentials, portal + +from scrapy.core.downloader.handlers.ftp import FTPDownloadHandler +from scrapy.http import HtmlResponse, Request, Response +from scrapy.http.response.text import TextResponse +from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future +from scrapy.utils.misc import build_from_crawler +from scrapy.utils.python import to_bytes +from scrapy.utils.spider import DefaultSpider +from scrapy.utils.test import get_crawler + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator, Generator + + +class TestFTPBase: + username = "scrapy" + password = "passwd" + req_meta: dict[str, Any] = {"ftp_user": username, "ftp_password": password} + + test_files = ( + ("file.txt", b"I have the power!"), + ("file with spaces.txt", b"Moooooooooo power!"), + ("html-file-without-extension", b"\n."), + ) + + def _create_files(self, root: Path) -> None: + userdir = root / self.username + userdir.mkdir() + for filename, content in self.test_files: + (userdir / filename).write_bytes(content) + + def _get_factory(self, root): + from twisted.protocols.ftp import FTPFactory, FTPRealm + + realm = FTPRealm(anonymousRoot=str(root), userHome=str(root)) + p = portal.Portal(realm) + users_checker = checkers.InMemoryUsernamePasswordDatabaseDontUse() + users_checker.addUser(self.username, self.password) + p.registerChecker(users_checker, credentials.IUsernamePassword) + return FTPFactory(portal=p) + + @async_yield_fixture + async def server_url(self, tmp_path: Path) -> AsyncGenerator[str]: + from twisted.internet import reactor + + self._create_files(tmp_path) + factory = self._get_factory(tmp_path) + port = reactor.listenTCP(0, factory, interface="127.0.0.1") + portno = port.getHost().port + + yield f"https://127.0.0.1:{portno}/" + + await port.stopListening() + + @staticmethod + @pytest.fixture + def dh() -> Generator[FTPDownloadHandler]: + crawler = get_crawler() + dh = build_from_crawler(FTPDownloadHandler, crawler) + + yield dh + + # if the test was skipped, there will be no client attribute + if hasattr(dh, "client"): + assert dh.client.transport + dh.client.transport.loseConnection() + + @staticmethod + async def download_request(dh: FTPDownloadHandler, request: Request) -> Response: + return await maybe_deferred_to_future( + dh.download_request(request, DefaultSpider()) + ) + + @deferred_f_from_coro_f + async def test_ftp_download_success( + self, server_url: str, dh: FTPDownloadHandler + ) -> None: + request = Request(url=server_url + "file.txt", meta=self.req_meta) + r = await self.download_request(dh, request) + assert r.status == 200 + assert r.body == b"I have the power!" + assert r.headers == {b"Local Filename": [b""], b"Size": [b"17"]} + assert r.protocol is None + + @deferred_f_from_coro_f + async def test_ftp_download_path_with_spaces( + self, server_url: str, dh: FTPDownloadHandler + ) -> None: + request = Request( + url=server_url + "file with spaces.txt", + meta=self.req_meta, + ) + r = await self.download_request(dh, request) + assert r.status == 200 + assert r.body == b"Moooooooooo power!" + assert r.headers == {b"Local Filename": [b""], b"Size": [b"18"]} + + @deferred_f_from_coro_f + async def test_ftp_download_nonexistent( + self, server_url: str, dh: FTPDownloadHandler + ) -> None: + request = Request(url=server_url + "nonexistent.txt", meta=self.req_meta) + r = await self.download_request(dh, request) + assert r.status == 404 + + @deferred_f_from_coro_f + async def test_ftp_local_filename( + self, server_url: str, dh: FTPDownloadHandler + ) -> None: + f, local_fname = mkstemp() + fname_bytes = to_bytes(local_fname) + local_path = Path(local_fname) + os.close(f) + meta = {"ftp_local_filename": fname_bytes} + meta.update(self.req_meta) + request = Request(url=server_url + "file.txt", meta=meta) + r = await self.download_request(dh, request) + assert r.body == fname_bytes + assert r.headers == {b"Local Filename": [fname_bytes], b"Size": [b"17"]} + assert local_path.exists() + assert local_path.read_bytes() == b"I have the power!" + local_path.unlink() + + @pytest.mark.parametrize( + ("filename", "response_class"), + [ + ("file.txt", TextResponse), + ("html-file-without-extension", HtmlResponse), + ], + ) + @deferred_f_from_coro_f + async def test_response_class( + self, + filename: str, + response_class: type[Response], + server_url: str, + dh: FTPDownloadHandler, + ) -> None: + f, local_fname = mkstemp() + local_fname_path = Path(local_fname) + os.close(f) + meta = {} + meta.update(self.req_meta) + request = Request(url=server_url + filename, meta=meta) + r = await self.download_request(dh, request) + assert type(r) is response_class # pylint: disable=unidiomatic-typecheck + local_fname_path.unlink() + + +class TestFTP(TestFTPBase): + @deferred_f_from_coro_f + async def test_invalid_credentials( + self, server_url: str, dh: FTPDownloadHandler, reactor_pytest: str + ) -> None: + if reactor_pytest == "asyncio" and sys.platform == "win32": + pytest.skip( + "This test produces DirtyReactorAggregateError on Windows with asyncio" + ) + + from twisted.protocols.ftp import ConnectionLost + + meta = dict(self.req_meta) + meta.update({"ftp_password": "invalid"}) + request = Request(url=server_url + "file.txt", meta=meta) + with pytest.raises(ConnectionLost): + await self.download_request(dh, request) + + +class TestAnonymousFTP(TestFTPBase): + username = "anonymous" + req_meta = {} + + def _create_files(self, root: Path) -> None: + for filename, content in self.test_files: + (root / filename).write_bytes(content) + + def _get_factory(self, tmp_path): + from twisted.protocols.ftp import FTPFactory, FTPRealm + + realm = FTPRealm(anonymousRoot=str(tmp_path)) + p = portal.Portal(realm) + p.registerChecker(checkers.AllowAnonymousAccess(), credentials.IAnonymous) + return FTPFactory(portal=p, userAnonymous=self.username) diff --git a/tests/test_downloader_handler_twisted_http10.py b/tests/test_downloader_handler_twisted_http10.py index bc306aa07..c3e0aa7c4 100644 --- a/tests/test_downloader_handler_twisted_http10.py +++ b/tests/test_downloader_handler_twisted_http10.py @@ -8,12 +8,16 @@ import pytest from scrapy.core.downloader.handlers.http10 import HTTP10DownloadHandler from scrapy.http import Request -from scrapy.spiders import Spider from scrapy.utils.defer import deferred_f_from_coro_f -from tests.test_downloader_handlers_http_base import TestHttpBase, TestHttpProxyBase +from tests.test_downloader_handlers_http_base import ( + TestHttpBase, + TestHttpProxyBase, + download_request, +) if TYPE_CHECKING: from scrapy.core.downloader.handlers import DownloadHandlerProtocol + from tests.mockserver.http import MockServer class HTTP10DownloadHandlerMixin: @@ -27,20 +31,26 @@ class TestHttp10(HTTP10DownloadHandlerMixin, TestHttpBase): """HTTP 1.0 test case""" @deferred_f_from_coro_f - async def test_protocol(self): - request = Request(self.getURL("host"), method="GET") - response = await self.download_request(request, Spider("foo")) + async def test_protocol( + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol + ) -> None: + request = Request( + mockserver.url("/host", is_secure=self.is_secure), method="GET" + ) + response = await download_request(download_handler, request) assert response.protocol == "HTTP/1.0" class TestHttps10(TestHttp10): - scheme = "https" + is_secure = True @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") class TestHttp10Proxy(HTTP10DownloadHandlerMixin, TestHttpProxyBase): - def test_download_with_proxy_https_timeout(self): + @deferred_f_from_coro_f + async def test_download_with_proxy_https_timeout(self): pytest.skip("Not implemented") - def test_download_with_proxy_without_http_scheme(self): + @deferred_f_from_coro_f + async def test_download_with_proxy_without_http_scheme(self): pytest.skip("Not implemented") diff --git a/tests/test_downloader_handler_twisted_http11.py b/tests/test_downloader_handler_twisted_http11.py index 7b26ce03f..79b2a6fc5 100644 --- a/tests/test_downloader_handler_twisted_http11.py +++ b/tests/test_downloader_handler_twisted_http11.py @@ -7,13 +7,13 @@ from typing import TYPE_CHECKING, Any from scrapy.core.downloader.handlers.http11 import HTTP11DownloadHandler from tests.test_downloader_handlers_http_base import ( TestHttp11Base, - TestHttpMockServerBase, TestHttpProxyBase, TestHttps11Base, TestHttpsCustomCiphersBase, TestHttpsInvalidDNSIdBase, TestHttpsInvalidDNSPatternBase, TestHttpsWrongHostnameBase, + TestHttpWithCrawlerBase, TestSimpleHttpsBase, ) @@ -57,7 +57,7 @@ class TestHttps11CustomCiphers(HTTP11DownloadHandlerMixin, TestHttpsCustomCipher pass -class TestHttp11MockServer(TestHttpMockServerBase): +class TestHttp11WithCrawler(TestHttpWithCrawlerBase): @property def settings_dict(self) -> dict[str, Any] | None: return None # default handler settings diff --git a/tests/test_downloader_handler_twisted_http2.py b/tests/test_downloader_handler_twisted_http2.py index 3e685bb28..3e3e677a2 100644 --- a/tests/test_downloader_handler_twisted_http2.py +++ b/tests/test_downloader_handler_twisted_http2.py @@ -9,32 +9,27 @@ from unittest import mock import pytest from testfixtures import LogCapture from twisted.internet import defer, error -from twisted.web import server from twisted.web.error import SchemeNotSupported from twisted.web.http import H2_ENABLED from scrapy.http import Request from scrapy.spiders import Spider -from scrapy.utils.defer import ( - deferred_f_from_coro_f, - maybe_deferred_to_future, -) -from scrapy.utils.misc import build_from_crawler -from scrapy.utils.test import get_crawler -from tests.mockserver import ssl_context_factory +from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future from tests.test_downloader_handlers_http_base import ( - TestHttpMockServerBase, TestHttpProxyBase, TestHttps11Base, TestHttpsCustomCiphersBase, TestHttpsInvalidDNSIdBase, TestHttpsInvalidDNSPatternBase, TestHttpsWrongHostnameBase, - UriResource, + TestHttpWithCrawlerBase, + download_request, ) if TYPE_CHECKING: from scrapy.core.downloader.handlers import DownloadHandlerProtocol + from tests.mockserver.http import MockServer + from tests.mockserver.proxy_echo import ProxyEchoMockServer pytestmark = pytest.mark.skipif( @@ -46,7 +41,9 @@ class H2DownloadHandlerMixin: @property def download_handler_cls(self) -> type[DownloadHandlerProtocol]: # the import can fail when H2_ENABLED is False - from scrapy.core.downloader.handlers.http2 import H2DownloadHandler + from scrapy.core.downloader.handlers.http2 import ( # noqa: PLC0415 + H2DownloadHandler, + ) return H2DownloadHandler @@ -55,93 +52,104 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base): HTTP2_DATALOSS_SKIP_REASON = "Content-Length mismatch raises InvalidBodyLengthError" @deferred_f_from_coro_f - async def test_protocol(self): - request = Request(self.getURL("host"), method="GET") - response = await self.download_request(request, Spider("foo")) + async def test_protocol( + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol + ) -> None: + request = Request( + mockserver.url("/host", is_secure=self.is_secure), method="GET" + ) + response = await download_request(download_handler, request) assert response.protocol == "h2" @deferred_f_from_coro_f - async def test_download_with_maxsize_very_large_file(self): + async def test_download_with_maxsize_very_large_file( + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol + ) -> None: from twisted.internet import reactor with mock.patch("scrapy.core.http2.stream.logger") as logger: - request = Request(self.getURL("largechunkedfile")) + request = Request( + mockserver.url("/largechunkedfile", is_secure=self.is_secure) + ) - def check(logger): + def check(logger: mock.Mock) -> None: logger.error.assert_called_once_with(mock.ANY) with pytest.raises((defer.CancelledError, error.ConnectionAborted)): - await self.download_request( - request, Spider("foo", download_maxsize=1500) + await download_request( + download_handler, request, Spider("foo", download_maxsize=1500) ) # As the error message is logged in the dataReceived callback, we # have to give a bit of time to the reactor to process the queue # after closing the connection. - d = defer.Deferred() + d: defer.Deferred[mock.Mock] = defer.Deferred() d.addCallback(check) reactor.callLater(0.1, d.callback, logger) await maybe_deferred_to_future(d) @deferred_f_from_coro_f - async def test_unsupported_scheme(self): + async def test_unsupported_scheme( + self, download_handler: DownloadHandlerProtocol + ) -> None: request = Request("ftp://unsupported.scheme") with pytest.raises(SchemeNotSupported): - await self.download_request(request, Spider("foo")) + await download_request(download_handler, request) - def test_download_broken_content_cause_data_loss(self, url="broken"): + def test_download_cause_data_loss(self) -> None: # type: ignore[override] pytest.skip(self.HTTP2_DATALOSS_SKIP_REASON) - def test_download_broken_chunked_content_cause_data_loss(self): + def test_download_allow_data_loss(self) -> None: # type: ignore[override] pytest.skip(self.HTTP2_DATALOSS_SKIP_REASON) - def test_download_broken_content_allow_data_loss(self, url="broken"): - pytest.skip(self.HTTP2_DATALOSS_SKIP_REASON) - - def test_download_broken_chunked_content_allow_data_loss(self): - pytest.skip(self.HTTP2_DATALOSS_SKIP_REASON) - - def test_download_broken_content_allow_data_loss_via_setting(self, url="broken"): - pytest.skip(self.HTTP2_DATALOSS_SKIP_REASON) - - def test_download_broken_chunked_content_allow_data_loss_via_setting(self): + def test_download_allow_data_loss_via_setting(self) -> None: # type: ignore[override] pytest.skip(self.HTTP2_DATALOSS_SKIP_REASON) @deferred_f_from_coro_f - async def test_concurrent_requests_same_domain(self): - spider = Spider("foo") + async def test_concurrent_requests_same_domain( + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol + ) -> None: + request1 = Request(mockserver.url("/text", is_secure=self.is_secure)) + response1 = await download_request(download_handler, request1) + assert response1.body == b"Works" - request1 = Request(self.getURL("file")) - response1 = await self.download_request(request1, spider) - assert response1.body == b"0123456789" - - request2 = Request(self.getURL("echo"), method="POST") - response2 = await self.download_request(request2, spider) + request2 = Request( + mockserver.url("/echo", is_secure=self.is_secure), method="POST" + ) + response2 = await download_request(download_handler, request2) assert response2.headers["Content-Length"] == b"79" @pytest.mark.xfail(reason="https://github.com/python-hyper/h2/issues/1247") @deferred_f_from_coro_f - async def test_connect_request(self): - request = Request(self.getURL("file"), method="CONNECT") - response = await self.download_request(request, Spider("foo")) + async def test_connect_request( + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol + ) -> None: + request = Request( + mockserver.url("/file", is_secure=self.is_secure), method="CONNECT" + ) + response = await download_request(download_handler, request) assert response.body == b"" @deferred_f_from_coro_f - async def test_custom_content_length_good(self): - request = Request(self.getURL("contentlength")) + async def test_custom_content_length_good( + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol + ) -> None: + request = Request(mockserver.url("/contentlength", is_secure=self.is_secure)) custom_content_length = str(len(request.body)) request.headers["Content-Length"] = custom_content_length - response = await self.download_request(request, Spider("foo")) + response = await download_request(download_handler, request) assert response.text == custom_content_length @deferred_f_from_coro_f - async def test_custom_content_length_bad(self): - request = Request(self.getURL("contentlength")) + async def test_custom_content_length_bad( + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol + ) -> None: + request = Request(mockserver.url("/contentlength", is_secure=self.is_secure)) actual_content_length = str(len(request.body)) bad_content_length = str(len(request.body) + 1) request.headers["Content-Length"] = bad_content_length with LogCapture() as log: - response = await self.download_request(request, Spider("foo")) + response = await download_request(download_handler, request) assert response.text == actual_content_length log.check_present( ( @@ -154,12 +162,14 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base): ) @deferred_f_from_coro_f - async def test_duplicate_header(self): - request = Request(self.getURL("echo")) + async def test_duplicate_header( + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol + ) -> None: + request = Request(mockserver.url("/echo", is_secure=self.is_secure)) header, value1, value2 = "Custom-Header", "foo", "bar" request.headers.appendlist(header, value1) request.headers.appendlist(header, value2) - response = await self.download_request(request, Spider("foo")) + response = await download_request(download_handler, request) assert json.loads(response.text)["headers"][header] == [value1, value2] @@ -181,7 +191,7 @@ class TestHttps2CustomCiphers(H2DownloadHandlerMixin, TestHttpsCustomCiphersBase pass -class TestHttp2MockServer(TestHttpMockServerBase): +class TestHttp2WithCrawler(TestHttpWithCrawlerBase): """HTTP 2.0 test case with MockServer""" @property @@ -196,36 +206,31 @@ class TestHttp2MockServer(TestHttpMockServerBase): class TestHttps2Proxy(H2DownloadHandlerMixin, TestHttpProxyBase): - # only used for HTTPS tests - keyfile = "keys/localhost.key" - certfile = "keys/localhost.crt" - - scheme = "https" - host = "127.0.0.1" - + is_secure = True expected_http_proxy_request_body = b"/" - def setUp(self): - from twisted.internet import reactor - - site = server.Site(UriResource(), timeout=None) - self.port = reactor.listenSSL( - 0, - site, - ssl_context_factory(self.keyfile, self.certfile), - interface=self.host, - ) - self.portno = self.port.getHost().port - self.download_handler = build_from_crawler( - self.download_handler_cls, get_crawler() - ) - - def getURL(self, path): - return f"{self.scheme}://{self.host}:{self.portno}/{path}" - @deferred_f_from_coro_f - async def test_download_with_proxy_https_timeout(self): + async def test_download_with_proxy_https_timeout( + self, + proxy_mockserver: ProxyEchoMockServer, + download_handler: DownloadHandlerProtocol, + ) -> None: with pytest.raises(NotImplementedError): await maybe_deferred_to_future( - super().test_download_with_proxy_https_timeout() + super().test_download_with_proxy_https_timeout( + proxy_mockserver, download_handler + ) + ) + + @deferred_f_from_coro_f + async def test_download_with_proxy_without_http_scheme( + self, + proxy_mockserver: ProxyEchoMockServer, + download_handler: DownloadHandlerProtocol, + ) -> None: + with pytest.raises(SchemeNotSupported): + await maybe_deferred_to_future( + super().test_download_with_proxy_without_http_scheme( + proxy_mockserver, download_handler + ) ) diff --git a/tests/test_downloader_handlers.py b/tests/test_downloader_handlers.py index 518dc6b24..e37932fbb 100644 --- a/tests/test_downloader_handlers.py +++ b/tests/test_downloader_handlers.py @@ -4,35 +4,23 @@ from __future__ import annotations import contextlib import os -import shutil -import sys from pathlib import Path from tempfile import mkdtemp, mkstemp from unittest import mock import pytest -from twisted.cred import checkers, credentials, portal -from twisted.internet.defer import inlineCallbacks -from twisted.protocols.ftp import FTPFactory, FTPRealm -from twisted.trial import unittest from w3lib.url import path_to_file_uri from scrapy.core.downloader.handlers import DownloadHandlers from scrapy.core.downloader.handlers.datauri import DataURIDownloadHandler from scrapy.core.downloader.handlers.file import FileDownloadHandler -from scrapy.core.downloader.handlers.ftp import FTPDownloadHandler from scrapy.core.downloader.handlers.s3 import S3DownloadHandler from scrapy.exceptions import NotConfigured -from scrapy.http import HtmlResponse, Request, Response -from scrapy.http.response.text import TextResponse +from scrapy.http import Request, Response from scrapy.responsetypes import responsetypes -from scrapy.spiders import Spider -from scrapy.utils.defer import ( - deferred_f_from_coro_f, - maybe_deferred_to_future, -) +from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future from scrapy.utils.misc import build_from_crawler -from scrapy.utils.python import to_bytes +from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler @@ -95,27 +83,27 @@ class TestLoad: assert "scheme" not in dh._notconfigured -class TestFile(unittest.TestCase): - def setUp(self): +class TestFile: + def setup_method(self): # add a special char to check that they are handled correctly self.fd, self.tmpname = mkstemp(suffix="^") Path(self.tmpname).write_text("0123456789", encoding="utf-8") self.download_handler = build_from_crawler(FileDownloadHandler, get_crawler()) - def tearDown(self): + def teardown_method(self): os.close(self.fd) Path(self.tmpname).unlink() - async def download_request(self, request: Request, spider: Spider) -> Response: + async def download_request(self, request: Request) -> Response: return await maybe_deferred_to_future( - self.download_handler.download_request(request, spider) + self.download_handler.download_request(request, DefaultSpider()) ) @deferred_f_from_coro_f async def test_download(self): request = Request(path_to_file_uri(self.tmpname)) assert request.url.upper().endswith("%5E") - response = await self.download_request(request, Spider("foo")) + response = await self.download_request(request) assert response.url == request.url assert response.status == 200 assert response.body == b"0123456789" @@ -126,7 +114,7 @@ class TestFile(unittest.TestCase): request = Request(path_to_file_uri(mkdtemp())) # the specific exception differs between platforms with pytest.raises(OSError): # noqa: PT011 - await self.download_request(request, Spider("foo")) + await self.download_request(request) class HttpDownloadHandlerMock: @@ -148,7 +136,7 @@ class TestS3Anon: # anon=True, # implicit ) self.download_request = self.s3reqh.download_request - self.spider = Spider("foo") + self.spider = DefaultSpider() def test_anon_request(self): req = Request("s3://aws-publicdatasets/") @@ -179,12 +167,12 @@ class TestS3: httpdownloadhandler=HttpDownloadHandlerMock, ) self.download_request = s3reqh.download_request - self.spider = Spider("foo") + self.spider = DefaultSpider() @contextlib.contextmanager def _mocked_date(self, date): try: - import botocore.auth # noqa: F401 + import botocore.auth # noqa: F401,PLC0415 except ImportError: yield else: @@ -307,209 +295,28 @@ class TestS3: ) -class TestFTPBase(unittest.TestCase): - username = "scrapy" - password = "passwd" - req_meta = {"ftp_user": username, "ftp_password": password} - - test_files = ( - ("file.txt", b"I have the power!"), - ("file with spaces.txt", b"Moooooooooo power!"), - ("html-file-without-extension", b"\n."), - ) - - def setUp(self): - from twisted.internet import reactor - - # setup dirs and test file - self.directory = Path(mkdtemp()) - userdir = self.directory / self.username - userdir.mkdir() - for filename, content in self.test_files: - (userdir / filename).write_bytes(content) - - # setup server - realm = FTPRealm( - anonymousRoot=str(self.directory), userHome=str(self.directory) - ) - p = portal.Portal(realm) - users_checker = checkers.InMemoryUsernamePasswordDatabaseDontUse() - users_checker.addUser(self.username, self.password) - p.registerChecker(users_checker, credentials.IUsernamePassword) - self.factory = FTPFactory(portal=p) - self.port = reactor.listenTCP(0, self.factory, interface="127.0.0.1") - self.portNum = self.port.getHost().port - crawler = get_crawler() - self.download_handler = build_from_crawler(FTPDownloadHandler, crawler) - - @inlineCallbacks - def tearDown(self): - yield self.port.stopListening() - shutil.rmtree(self.directory) - - def _add_test_callbacks(self, deferred, callback=None, errback=None): - def _clean(data): - self.download_handler.client.transport.loseConnection() - return data - - deferred.addCallback(_clean) - if callback: - deferred.addCallback(callback) - if errback: - deferred.addErrback(errback) - return deferred - - def test_ftp_download_success(self): - request = Request( - url=f"ftp://127.0.0.1:{self.portNum}/file.txt", meta=self.req_meta - ) - d = self.download_handler.download_request(request, None) - - def _test(r): - assert r.status == 200 - assert r.body == b"I have the power!" - assert r.headers == {b"Local Filename": [b""], b"Size": [b"17"]} - assert r.protocol is None - - return self._add_test_callbacks(d, _test) - - def test_ftp_download_path_with_spaces(self): - request = Request( - url=f"ftp://127.0.0.1:{self.portNum}/file with spaces.txt", - meta=self.req_meta, - ) - d = self.download_handler.download_request(request, None) - - def _test(r): - assert r.status == 200 - assert r.body == b"Moooooooooo power!" - assert r.headers == {b"Local Filename": [b""], b"Size": [b"18"]} - - return self._add_test_callbacks(d, _test) - - def test_ftp_download_nonexistent(self): - request = Request( - url=f"ftp://127.0.0.1:{self.portNum}/nonexistent.txt", meta=self.req_meta - ) - d = self.download_handler.download_request(request, None) - - def _test(r): - assert r.status == 404 - - return self._add_test_callbacks(d, _test) - - def test_ftp_local_filename(self): - f, local_fname = mkstemp() - fname_bytes = to_bytes(local_fname) - local_fname = Path(local_fname) - os.close(f) - meta = {"ftp_local_filename": fname_bytes} - meta.update(self.req_meta) - request = Request(url=f"ftp://127.0.0.1:{self.portNum}/file.txt", meta=meta) - d = self.download_handler.download_request(request, None) - - def _test(r): - assert r.body == fname_bytes - assert r.headers == {b"Local Filename": [fname_bytes], b"Size": [b"17"]} - assert local_fname.exists() - assert local_fname.read_bytes() == b"I have the power!" - local_fname.unlink() - - return self._add_test_callbacks(d, _test) - - def _test_response_class(self, filename, response_class): - f, local_fname = mkstemp() - local_fname = Path(local_fname) - os.close(f) - meta = {} - meta.update(self.req_meta) - request = Request(url=f"ftp://127.0.0.1:{self.portNum}/{filename}", meta=meta) - d = self.download_handler.download_request(request, None) - - def _test(r): - assert type(r) is response_class # pylint: disable=unidiomatic-typecheck - local_fname.unlink() - - return self._add_test_callbacks(d, _test) - - def test_response_class_from_url(self): - return self._test_response_class("file.txt", TextResponse) - - def test_response_class_from_body(self): - return self._test_response_class("html-file-without-extension", HtmlResponse) - - -class TestFTP(TestFTPBase): - def test_invalid_credentials(self): - if self.reactor_pytest != "default" and sys.platform == "win32": - pytest.skip( - "This test produces DirtyReactorAggregateError on Windows with asyncio" - ) - from twisted.protocols.ftp import ConnectionLost - - meta = dict(self.req_meta) - meta.update({"ftp_password": "invalid"}) - request = Request(url=f"ftp://127.0.0.1:{self.portNum}/file.txt", meta=meta) - d = self.download_handler.download_request(request, None) - - def _test(r): - assert r.type == ConnectionLost - - return self._add_test_callbacks(d, errback=_test) - - -class TestAnonymousFTP(TestFTPBase): - username = "anonymous" - req_meta = {} - - def setUp(self): - from twisted.internet import reactor - - # setup dir and test file - self.directory = Path(mkdtemp()) - for filename, content in self.test_files: - (self.directory / filename).write_bytes(content) - - # setup server for anonymous access - realm = FTPRealm(anonymousRoot=str(self.directory)) - p = portal.Portal(realm) - p.registerChecker(checkers.AllowAnonymousAccess(), credentials.IAnonymous) - - self.factory = FTPFactory(portal=p, userAnonymous=self.username) - self.port = reactor.listenTCP(0, self.factory, interface="127.0.0.1") - self.portNum = self.port.getHost().port - crawler = get_crawler() - self.download_handler = build_from_crawler(FTPDownloadHandler, crawler) - - @inlineCallbacks - def tearDown(self): - yield self.port.stopListening() - shutil.rmtree(self.directory) - - -class TestDataURI(unittest.TestCase): - def setUp(self): +class TestDataURI: + def setup_method(self): crawler = get_crawler() self.download_handler = build_from_crawler(DataURIDownloadHandler, crawler) - self.spider = Spider("foo") - async def download_request(self, request: Request, spider: Spider) -> Response: + async def download_request(self, request: Request) -> Response: return await maybe_deferred_to_future( - self.download_handler.download_request(request, spider) + self.download_handler.download_request(request, DefaultSpider()) ) @deferred_f_from_coro_f async def test_response_attrs(self): uri = "data:,A%20brief%20note" request = Request(uri) - response = await self.download_request(request, self.spider) + response = await self.download_request(request) assert response.url == uri assert not response.headers @deferred_f_from_coro_f async def test_default_mediatype_encoding(self): request = Request("data:,A%20brief%20note") - response = await self.download_request(request, self.spider) + response = await self.download_request(request) assert response.text == "A brief note" assert type(response) is responsetypes.from_mimetype("text/plain") # pylint: disable=unidiomatic-typecheck assert response.encoding == "US-ASCII" @@ -517,7 +324,7 @@ class TestDataURI(unittest.TestCase): @deferred_f_from_coro_f async def test_default_mediatype(self): request = Request("data:;charset=iso-8859-7,%be%d3%be") - response = await self.download_request(request, self.spider) + response = await self.download_request(request) assert response.text == "\u038e\u03a3\u038e" assert type(response) is responsetypes.from_mimetype("text/plain") # pylint: disable=unidiomatic-typecheck assert response.encoding == "iso-8859-7" @@ -525,7 +332,7 @@ class TestDataURI(unittest.TestCase): @deferred_f_from_coro_f async def test_text_charset(self): request = Request("data:text/plain;charset=iso-8859-7,%be%d3%be") - response = await self.download_request(request, self.spider) + response = await self.download_request(request) assert response.text == "\u038e\u03a3\u038e" assert response.body == b"\xbe\xd3\xbe" assert response.encoding == "iso-8859-7" @@ -537,7 +344,7 @@ class TestDataURI(unittest.TestCase): "charset=utf-8;bar=%22foo;%5C%22 foo ;/,%22" ",%CE%8E%CE%A3%CE%8E" ) - response = await self.download_request(request, self.spider) + response = await self.download_request(request) assert response.text == "\u038e\u03a3\u038e" assert type(response) is responsetypes.from_mimetype("text/plain") # pylint: disable=unidiomatic-typecheck assert response.encoding == "utf-8" @@ -545,11 +352,11 @@ class TestDataURI(unittest.TestCase): @deferred_f_from_coro_f async def test_base64(self): request = Request("data:text/plain;base64,SGVsbG8sIHdvcmxkLg%3D%3D") - response = await self.download_request(request, self.spider) + response = await self.download_request(request) assert response.text == "Hello, world." @deferred_f_from_coro_f async def test_protocol(self): request = Request("data:,") - response = await self.download_request(request, self.spider) + response = await self.download_request(request) assert response.protocol is None diff --git a/tests/test_downloader_handlers_http_base.py b/tests/test_downloader_handlers_http_base.py index 9b2c49fd4..8fefc0dd7 100644 --- a/tests/test_downloader_handlers_http_base.py +++ b/tests/test_downloader_handlers_http_base.py @@ -2,277 +2,289 @@ from __future__ import annotations +import gzip import json -import shutil import sys from abc import ABC, abstractmethod -from pathlib import Path -from tempfile import mkdtemp +from http import HTTPStatus from typing import TYPE_CHECKING, Any from unittest import mock import pytest +from pytest_twisted import async_yield_fixture from testfixtures import LogCapture from twisted.internet import defer, error -from twisted.internet.defer import inlineCallbacks, maybeDeferred -from twisted.protocols.policies import WrappingFactory -from twisted.trial import unittest -from twisted.web import resource, server, static, util from twisted.web._newclient import ResponseFailed from twisted.web.http import _DataLoss from scrapy.http import Headers, HtmlResponse, Request, Response, TextResponse from scrapy.spiders import Spider +from scrapy.utils.asyncio import call_later from scrapy.utils.defer import ( deferred_f_from_coro_f, deferred_from_coro, maybe_deferred_to_future, ) from scrapy.utils.misc import build_from_crawler -from scrapy.utils.python import to_bytes +from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler from tests import NON_EXISTING_RESOLVABLE -from tests.mockserver import ( - Echo, - ForeverTakingResource, - HostHeaderResource, - MockServer, - NoLengthResource, - PayloadResource, - ssl_context_factory, -) +from tests.mockserver.proxy_echo import ProxyEchoMockServer +from tests.mockserver.simple_https import SimpleMockServer from tests.spiders import SingleRequestSpider if TYPE_CHECKING: + from collections.abc import AsyncGenerator, Generator + from scrapy.core.downloader.handlers import DownloadHandlerProtocol + from tests.mockserver.http import MockServer -class ContentLengthHeaderResource(resource.Resource): - """ - A testing resource which renders itself as the value of the Content-Length - header from the request. - """ - - def render(self, request): - return request.requestHeaders.getRawHeaders(b"content-length")[0] +async def download_request( + download_handler: DownloadHandlerProtocol, + request: Request, + spider: Spider = DefaultSpider(), +) -> Response: + return await maybe_deferred_to_future( + download_handler.download_request(request, spider) + ) -class ChunkedResource(resource.Resource): - def render(self, request): - from twisted.internet import reactor - - def response(): - request.write(b"chunked ") - request.write(b"content\n") - request.finish() - - reactor.callLater(0, response) - return server.NOT_DONE_YET +async def close_dh(dh: DownloadHandlerProtocol) -> None: + # needed because the interface of close() is not clearly defined + if not hasattr(dh, "close"): + return + c = dh.close() + if c is None: + return + # covers coroutines and Deferreds; won't work if close() uses Futures inside + await c -class BrokenChunkedResource(resource.Resource): - def render(self, request): - from twisted.internet import reactor - - def response(): - request.write(b"chunked ") - request.write(b"content\n") - # Disable terminating chunk on finish. - request.chunked = False - closeConnection(request) - - reactor.callLater(0, response) - return server.NOT_DONE_YET - - -class BrokenDownloadResource(resource.Resource): - def render(self, request): - from twisted.internet import reactor - - def response(): - request.setHeader(b"Content-Length", b"20") - request.write(b"partial") - closeConnection(request) - - reactor.callLater(0, response) - return server.NOT_DONE_YET - - -def closeConnection(request): - # We have to force a disconnection for HTTP/1.1 clients. Otherwise - # client keeps the connection open waiting for more data. - request.channel.loseConnection() - request.finish() - - -class EmptyContentTypeHeaderResource(resource.Resource): - """ - A testing resource which renders itself as the value of request body - without content-type header in response. - """ - - def render(self, request): - request.setHeader("content-type", "") - return request.content.read() - - -class LargeChunkedFileResource(resource.Resource): - def render(self, request): - from twisted.internet import reactor - - def response(): - for i in range(1024): - request.write(b"x" * 1024) - request.finish() - - reactor.callLater(0, response) - return server.NOT_DONE_YET - - -class DuplicateHeaderResource(resource.Resource): - def render(self, request): - request.responseHeaders.setRawHeaders(b"Set-Cookie", [b"a=b", b"c=d"]) - return b"" - - -class TestHttpBase(unittest.TestCase, ABC): - scheme = "http" - - # only used for HTTPS tests - keyfile = "keys/localhost.key" - certfile = "keys/localhost.crt" +class TestHttpBase(ABC): + is_secure = False @property @abstractmethod def download_handler_cls(self) -> type[DownloadHandlerProtocol]: raise NotImplementedError - def setUp(self): - from twisted.internet import reactor + @async_yield_fixture + async def download_handler(self) -> AsyncGenerator[DownloadHandlerProtocol]: + dh = build_from_crawler(self.download_handler_cls, get_crawler()) - self.tmpname = Path(mkdtemp()) - (self.tmpname / "file").write_bytes(b"0123456789") - r = static.File(str(self.tmpname)) - r.putChild(b"redirect", util.Redirect(b"/file")) - r.putChild(b"wait", ForeverTakingResource()) - r.putChild(b"hang-after-headers", ForeverTakingResource(write=True)) - r.putChild(b"nolength", NoLengthResource()) - r.putChild(b"host", HostHeaderResource()) - r.putChild(b"payload", PayloadResource()) - r.putChild(b"broken", BrokenDownloadResource()) - r.putChild(b"chunked", ChunkedResource()) - r.putChild(b"broken-chunked", BrokenChunkedResource()) - r.putChild(b"contentlength", ContentLengthHeaderResource()) - r.putChild(b"nocontenttype", EmptyContentTypeHeaderResource()) - r.putChild(b"largechunkedfile", LargeChunkedFileResource()) - r.putChild(b"duplicate-header", DuplicateHeaderResource()) - r.putChild(b"echo", Echo()) - self.site = server.Site(r, timeout=None) - self.wrapper = WrappingFactory(self.site) - self.host = "localhost" - if self.scheme == "https": - # Using WrappingFactory do not enable HTTP/2 failing all the - # tests with H2DownloadHandler - self.port = reactor.listenSSL( - 0, - self.site, - ssl_context_factory(self.keyfile, self.certfile), - interface=self.host, - ) - else: - self.port = reactor.listenTCP(0, self.wrapper, interface=self.host) - self.portno = self.port.getHost().port - self.download_handler = build_from_crawler( - self.download_handler_cls, get_crawler() - ) + yield dh - @inlineCallbacks - def tearDown(self): - yield self.port.stopListening() - if hasattr(self.download_handler, "close"): - yield self.download_handler.close() - shutil.rmtree(self.tmpname) - - def getURL(self, path): - return f"{self.scheme}://{self.host}:{self.portno}/{path}" - - async def download_request(self, request: Request, spider: Spider) -> Response: - return await maybe_deferred_to_future( - self.download_handler.download_request(request, spider) - ) + await close_dh(dh) @deferred_f_from_coro_f - async def test_download(self): - request = Request(self.getURL("file")) - response = await self.download_request(request, Spider("foo")) - assert response.body == b"0123456789" + async def test_download( + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol + ) -> None: + request = Request(mockserver.url("/text", is_secure=self.is_secure)) + response = await download_request(download_handler, request) + assert response.body == b"Works" @deferred_f_from_coro_f - async def test_download_head(self): - request = Request(self.getURL("file"), method="HEAD") - response = await self.download_request(request, Spider("foo")) + async def test_download_head( + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol + ) -> None: + request = Request( + mockserver.url("/text", is_secure=self.is_secure), method="HEAD" + ) + response = await download_request(download_handler, request) assert response.body == b"" + @pytest.mark.parametrize( + "http_status", + [ + pytest.param(http_status, id=f"status={http_status.value}") + for http_status in HTTPStatus + if http_status.value == 200 or http_status.value // 100 in (4, 5) + ], + ) @deferred_f_from_coro_f - async def test_redirect_status(self): - request = Request(self.getURL("redirect")) - response = await self.download_request(request, Spider("foo")) + async def test_download_has_correct_http_status_code( + self, + mockserver: MockServer, + download_handler: DownloadHandlerProtocol, + http_status: HTTPStatus, + ) -> None: + request = Request( + mockserver.url(f"/status?n={http_status.value}", is_secure=self.is_secure) + ) + response = await download_request(download_handler, request) + assert response.status == http_status.value + + @deferred_f_from_coro_f + async def test_server_receives_correct_request_headers( + self, + mockserver: MockServer, + download_handler: DownloadHandlerProtocol, + ) -> None: + request_headers = { + # common request headers + "Accept": "text/html", + "Accept-Charset": "utf-8", + "Accept-Datetime": "Thu, 31 May 2007 20:35:00 GMT", + "Accept-Encoding": "gzip, deflate", + # custom headers + "X-Custom-Header": "Custom Value", + } + + request = Request( + mockserver.url("/echo", is_secure=self.is_secure), + headers=request_headers, + ) + response = await download_request(download_handler, request) + assert response.status == HTTPStatus.OK + body = json.loads(response.body.decode("utf-8")) + assert "headers" in body + for header_name, header_value in request_headers.items(): + assert header_name in body["headers"] + assert body["headers"][header_name] == [header_value] + + @deferred_f_from_coro_f + async def test_server_receives_correct_request_body( + self, + mockserver: MockServer, + download_handler: DownloadHandlerProtocol, + ) -> None: + request_body = { + "message": "It works!", + } + request = Request( + mockserver.url("/echo", is_secure=self.is_secure), + body=json.dumps(request_body), + ) + response = await download_request(download_handler, request) + assert response.status == HTTPStatus.OK + body = json.loads(response.body.decode("utf-8")) + assert json.loads(body["body"]) == request_body + + @deferred_f_from_coro_f + async def test_download_has_correct_response_headers( + self, + mockserver: MockServer, + download_handler: DownloadHandlerProtocol, + ) -> None: + # these headers will be set on the response in the resource and returned + response_headers = { + # common response headers + "Access-Control-Allow-Origin": "*", + "Allow": "Get, Head", + "Age": "12", + "Cache-Control": "max-age=3600", + "Content-Encoding": "gzip", + "Content-MD5": "Q2hlY2sgSW50ZWdyaXR5IQ==", + "Content-Type": "text/html; charset=utf-8", + "Date": "Date: Tue, 15 Nov 1994 08:12:31 GMT", + "Pragma": "no-cache", + "Retry-After": "120", + "Set-Cookie": "CookieName=CookieValue; Max-Age=3600; Version=1", + "WWW-Authenticate": "Basic", + # custom headers + "X-Custom-Header": "Custom Header Value", + } + + request = Request( + mockserver.url("/response-headers", is_secure=self.is_secure), + headers={"content-type": "application/json"}, + body=json.dumps(response_headers), + ) + response = await download_request(download_handler, request) + assert response.status == 200 + for header_name, header_value in response_headers.items(): + assert header_name in response.headers, ( + f"Response was missing expected header {header_name}" + ) + assert response.headers[header_name] == bytes( + header_value, encoding="utf-8" + ) + + @deferred_f_from_coro_f + async def test_redirect_status( + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol + ) -> None: + request = Request(mockserver.url("/redirect", is_secure=self.is_secure)) + response = await download_request(download_handler, request) assert response.status == 302 @deferred_f_from_coro_f - async def test_redirect_status_head(self): - request = Request(self.getURL("redirect"), method="HEAD") - response = await self.download_request(request, Spider("foo")) + async def test_redirect_status_head( + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol + ) -> None: + request = Request( + mockserver.url("/redirect", is_secure=self.is_secure), method="HEAD" + ) + response = await download_request(download_handler, request) assert response.status == 302 @deferred_f_from_coro_f - async def test_timeout_download_from_spider_nodata_rcvd(self): - if self.reactor_pytest != "default" and sys.platform == "win32": + async def test_timeout_download_from_spider_nodata_rcvd( + self, + mockserver: MockServer, + download_handler: DownloadHandlerProtocol, + reactor_pytest: str, + ) -> None: + if reactor_pytest == "asyncio" and sys.platform == "win32": # https://twistedmatrix.com/trac/ticket/10279 pytest.skip( "This test produces DirtyReactorAggregateError on Windows with asyncio" ) # client connects but no data is received - spider = Spider("foo") meta = {"download_timeout": 0.5} - request = Request(self.getURL("wait"), meta=meta) - d = deferred_from_coro(self.download_request(request, spider)) + request = Request(mockserver.url("/wait", is_secure=self.is_secure), meta=meta) + d = deferred_from_coro(download_request(download_handler, request)) with pytest.raises((defer.TimeoutError, error.TimeoutError)): await maybe_deferred_to_future(d) @deferred_f_from_coro_f - async def test_timeout_download_from_spider_server_hangs(self): - if self.reactor_pytest != "default" and sys.platform == "win32": + async def test_timeout_download_from_spider_server_hangs( + self, + mockserver: MockServer, + download_handler: DownloadHandlerProtocol, + reactor_pytest: str, + ) -> None: + if reactor_pytest == "asyncio" and sys.platform == "win32": # https://twistedmatrix.com/trac/ticket/10279 pytest.skip( "This test produces DirtyReactorAggregateError on Windows with asyncio" ) # client connects, server send headers and some body bytes but hangs - spider = Spider("foo") meta = {"download_timeout": 0.5} - request = Request(self.getURL("hang-after-headers"), meta=meta) - d = deferred_from_coro(self.download_request(request, spider)) + request = Request( + mockserver.url("/hang-after-headers", is_secure=self.is_secure), meta=meta + ) + d = deferred_from_coro(download_request(download_handler, request)) with pytest.raises((defer.TimeoutError, error.TimeoutError)): await maybe_deferred_to_future(d) + @pytest.mark.parametrize("send_header", [True, False]) @deferred_f_from_coro_f - async def test_host_header_not_in_request_headers(self): - request = Request(self.getURL("host")) - response = await self.download_request(request, Spider("foo")) - assert response.body == to_bytes(f"{self.host}:{self.portno}") - assert not request.headers + async def test_host_header( + self, + send_header: bool, + mockserver: MockServer, + download_handler: DownloadHandlerProtocol, + ) -> None: + host_port = f"{mockserver.host}:{mockserver.port(is_secure=self.is_secure)}" + request = Request( + mockserver.url("/host", is_secure=self.is_secure), + headers={"Host": host_port} if send_header else {}, + ) + response = await download_request(download_handler, request) + assert response.body == host_port.encode() + if send_header: + assert request.headers.get("Host") == host_port.encode() + else: + assert not request.headers @deferred_f_from_coro_f - async def test_host_header_set_in_request_headers(self): - host = self.host + ":" + str(self.portno) - request = Request(self.getURL("host"), headers={"Host": host}) - response = await self.download_request(request, Spider("foo")) - assert response.body == host.encode() - assert request.headers.get("Host") == host.encode() - - @deferred_f_from_coro_f - async def test_content_length_zero_bodyless_post_request_headers(self): + async def test_content_length_zero_bodyless_post_request_headers( + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol + ) -> None: """Tests if "Content-Length: 0" is sent for bodyless POST requests. This is not strictly required by HTTP RFCs but can cause trouble @@ -283,192 +295,304 @@ class TestHttpBase(unittest.TestCase, ABC): https://github.com/kennethreitz/requests/issues/405 https://bugs.python.org/issue14721 """ - request = Request(self.getURL("contentlength"), method="POST") - response = await self.download_request(request, Spider("foo")) + request = Request( + mockserver.url("/contentlength", is_secure=self.is_secure), method="POST" + ) + response = await download_request(download_handler, request) assert response.body == b"0" @deferred_f_from_coro_f - async def test_content_length_zero_bodyless_post_only_one(self): - request = Request(self.getURL("echo"), method="POST") - response = await self.download_request(request, Spider("foo")) + async def test_content_length_zero_bodyless_post_only_one( + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol + ) -> None: + request = Request( + mockserver.url("/echo", is_secure=self.is_secure), method="POST" + ) + response = await download_request(download_handler, request) headers = Headers(json.loads(response.text)["headers"]) contentlengths = headers.getlist("Content-Length") assert len(contentlengths) == 1 assert contentlengths == [b"0"] @deferred_f_from_coro_f - async def test_payload(self): + async def test_payload( + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol + ) -> None: body = b"1" * 100 # PayloadResource requires body length to be 100 - request = Request(self.getURL("payload"), method="POST", body=body) - response = await self.download_request(request, Spider("foo")) + request = Request( + mockserver.url("/payload", is_secure=self.is_secure), + method="POST", + body=body, + ) + response = await download_request(download_handler, request) assert response.body == body @deferred_f_from_coro_f - async def test_response_header_content_length(self): - request = Request(self.getURL("file"), method=b"GET") - response = await self.download_request(request, Spider("foo")) - assert response.headers[b"content-length"] == b"159" - - async def _test_response_class( - self, filename: str, body: bytes, response_class: type[Response] + async def test_response_header_content_length( + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol ) -> None: - request = Request(self.getURL(filename), body=body) - response = await self.download_request(request, Spider("foo")) + request = Request( + mockserver.url("/text", is_secure=self.is_secure), method="GET" + ) + response = await download_request(download_handler, request) + assert response.headers[b"content-length"] == b"5" + + @pytest.mark.parametrize( + ("filename", "body", "response_class"), + [ + ("foo.html", b"", HtmlResponse), + ("foo", b"\n.", HtmlResponse), + ], + ) + @deferred_f_from_coro_f + async def test_response_class( + self, + filename: str, + body: bytes, + response_class: type[Response], + mockserver: MockServer, + download_handler: DownloadHandlerProtocol, + ) -> None: + request = Request( + mockserver.url(f"/{filename}", is_secure=self.is_secure), body=body + ) + response = await download_request(download_handler, request) assert type(response) is response_class # pylint: disable=unidiomatic-typecheck @deferred_f_from_coro_f - async def test_response_class_from_url(self): - await self._test_response_class("foo.html", b"", HtmlResponse) - - @deferred_f_from_coro_f - async def test_response_class_from_body(self): - await self._test_response_class( - "foo", - b"\n.", - HtmlResponse, - ) - - @deferred_f_from_coro_f - async def test_get_duplicate_header(self): - request = Request(self.getURL("duplicate-header")) - response = await self.download_request(request, Spider("foo")) + async def test_get_duplicate_header( + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol + ) -> None: + request = Request(mockserver.url("/duplicate-header", is_secure=self.is_secure)) + response = await download_request(download_handler, request) assert response.headers.getlist(b"Set-Cookie") == [b"a=b", b"c=d"] + @deferred_f_from_coro_f + async def test_download_is_not_automatically_gzip_decoded( + self, download_handler: DownloadHandlerProtocol, mockserver: MockServer + ) -> None: + """Test download handler does not automatically decode content using the scheme provided in Content-Encoding header""" + + data = "compress-me" + + # send a request to mock resource that gzip encodes the "data" url parameter + request = Request( + mockserver.url(f"/compress?data={data}", is_secure=self.is_secure), + headers={ + "accept-encoding": "gzip", + }, + ) + response = await download_request(download_handler, request) + + assert response.status == 200 + + # check that the Content-Encoding header is gzip + content_encoding = response.headers[b"Content-Encoding"] + assert content_encoding == b"gzip" + + # check that the response is still encoded + # by checking for the magic number that is always included at the start of a gzip encoding + # see https://datatracker.ietf.org/doc/html/rfc1952#page-5 section 2.3.1 + GZIP_MAGIC = b"\x1f\x8b" + assert response.body[:2] == GZIP_MAGIC, "Response body was not in gzip format" + + # check that a gzip decoding matches the data sent in the request + expected_decoding = bytes(data, encoding="utf-8") + assert gzip.decompress(response.body) == expected_decoding + + @deferred_f_from_coro_f + async def test_no_cookie_processing_or_persistence( + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol + ) -> None: + cookie_name = "foo" + cookie_value = "bar" + + # check that cookies are not modified + request = Request( + mockserver.url( + f"/set-cookie?{cookie_name}={cookie_value}", is_secure=self.is_secure + ) + ) + response = await download_request(download_handler, request) + assert response.status == 200 + set_cookie = response.headers.get(b"Set-Cookie") + assert set_cookie == f"{cookie_name}={cookie_value}".encode() + + # check that cookies are not sent in the next request + request = Request(mockserver.url("/echo", is_secure=self.is_secure)) + response = await download_request(download_handler, request) + assert response.status == 200 + headers = Headers(json.loads(response.text)["headers"]) + assert "Cookie" not in headers + assert "cookie" not in headers + class TestHttp11Base(TestHttpBase): """HTTP 1.1 test case""" @deferred_f_from_coro_f - async def test_download_without_maxsize_limit(self): - request = Request(self.getURL("file")) - response = await self.download_request(request, Spider("foo")) - assert response.body == b"0123456789" + async def test_download_without_maxsize_limit( + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol + ) -> None: + request = Request(mockserver.url("/text", is_secure=self.is_secure)) + response = await download_request(download_handler, request) + assert response.body == b"Works" @deferred_f_from_coro_f - async def test_response_class_choosing_request(self): + async def test_response_class_choosing_request( + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol + ) -> None: """Tests choosing of correct response type in case of Content-Type is empty but body contains text. """ body = b"Some plain text\ndata with tabs\t and null bytes\0" - request = Request(self.getURL("nocontenttype"), body=body) - response = await self.download_request(request, Spider("foo")) + request = Request( + mockserver.url("/nocontenttype", is_secure=self.is_secure), body=body + ) + response = await download_request(download_handler, request) assert type(response) is TextResponse # pylint: disable=unidiomatic-typecheck @deferred_f_from_coro_f - async def test_download_with_maxsize(self): - request = Request(self.getURL("file")) + async def test_download_with_maxsize( + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol + ) -> None: + request = Request(mockserver.url("/text", is_secure=self.is_secure)) # 10 is minimal size for this request and the limit is only counted on # response body. (regardless of headers) - response = await self.download_request( - request, Spider("foo", download_maxsize=10) + response = await download_request( + download_handler, request, Spider("foo", download_maxsize=5) ) - assert response.body == b"0123456789" + assert response.body == b"Works" with pytest.raises((defer.CancelledError, error.ConnectionAborted)): - await self.download_request(request, Spider("foo", download_maxsize=9)) + await download_request( + download_handler, request, Spider("foo", download_maxsize=4) + ) @deferred_f_from_coro_f - async def test_download_with_maxsize_very_large_file(self): - from twisted.internet import reactor - + async def test_download_with_maxsize_very_large_file( + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol + ) -> None: # TODO: the logger check is specific to scrapy.core.downloader.handlers.http11 with mock.patch("scrapy.core.downloader.handlers.http11.logger") as logger: - request = Request(self.getURL("largechunkedfile")) + request = Request( + mockserver.url("/largechunkedfile", is_secure=self.is_secure) + ) - def check(logger): + def check(logger: mock.Mock) -> None: logger.warning.assert_called_once_with(mock.ANY, mock.ANY) with pytest.raises((defer.CancelledError, error.ConnectionAborted)): - await self.download_request( - request, Spider("foo", download_maxsize=1500) + await download_request( + download_handler, request, Spider("foo", download_maxsize=1500) ) # As the error message is logged in the dataReceived callback, we # have to give a bit of time to the reactor to process the queue # after closing the connection. - d = defer.Deferred() + d: defer.Deferred[mock.Mock] = defer.Deferred() d.addCallback(check) - reactor.callLater(0.1, d.callback, logger) + call_later(0.1, d.callback, logger) await maybe_deferred_to_future(d) @deferred_f_from_coro_f - async def test_download_with_maxsize_per_req(self): + async def test_download_with_maxsize_per_req( + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol + ) -> None: meta = {"download_maxsize": 2} - request = Request(self.getURL("file"), meta=meta) + request = Request(mockserver.url("/text", is_secure=self.is_secure), meta=meta) with pytest.raises((defer.CancelledError, error.ConnectionAborted)): - await self.download_request(request, Spider("foo")) + await download_request(download_handler, request) @deferred_f_from_coro_f - async def test_download_with_small_maxsize_per_spider(self): - request = Request(self.getURL("file")) + async def test_download_with_small_maxsize_per_spider( + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol + ) -> None: + request = Request(mockserver.url("/text", is_secure=self.is_secure)) with pytest.raises((defer.CancelledError, error.ConnectionAborted)): - await self.download_request(request, Spider("foo", download_maxsize=2)) + await download_request( + download_handler, request, Spider("foo", download_maxsize=2) + ) @deferred_f_from_coro_f - async def test_download_with_large_maxsize_per_spider(self): - request = Request(self.getURL("file")) - response = await self.download_request( - request, Spider("foo", download_maxsize=100) + async def test_download_with_large_maxsize_per_spider( + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol + ) -> None: + request = Request(mockserver.url("/text", is_secure=self.is_secure)) + response = await download_request( + download_handler, request, Spider("foo", download_maxsize=100) ) - assert response.body == b"0123456789" + assert response.body == b"Works" @deferred_f_from_coro_f - async def test_download_chunked_content(self): - request = Request(self.getURL("chunked")) - response = await self.download_request(request, Spider("foo")) + async def test_download_chunked_content( + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol + ) -> None: + request = Request(mockserver.url("/chunked", is_secure=self.is_secure)) + response = await download_request(download_handler, request) assert response.body == b"chunked content\n" + @pytest.mark.parametrize("url", ["broken", "broken-chunked"]) @deferred_f_from_coro_f - async def test_download_broken_content_cause_data_loss( - self, url: str = "broken" + async def test_download_cause_data_loss( + self, + url: str, + mockserver: MockServer, + download_handler: DownloadHandlerProtocol, ) -> None: # TODO: this one checks for Twisted-specific exceptions - request = Request(self.getURL(url)) + request = Request(mockserver.url(f"/{url}", is_secure=self.is_secure)) with pytest.raises(ResponseFailed) as exc_info: - await self.download_request(request, Spider("foo")) + await download_request(download_handler, request) assert any(r.check(_DataLoss) for r in exc_info.value.reasons) - def test_download_broken_chunked_content_cause_data_loss(self): - return self.test_download_broken_content_cause_data_loss("broken-chunked") - + @pytest.mark.parametrize("url", ["broken", "broken-chunked"]) @deferred_f_from_coro_f - async def test_download_broken_content_allow_data_loss( - self, url: str = "broken" + async def test_download_allow_data_loss( + self, + url: str, + mockserver: MockServer, + download_handler: DownloadHandlerProtocol, ) -> None: - request = Request(self.getURL(url), meta={"download_fail_on_dataloss": False}) - response = await self.download_request(request, Spider("foo")) + request = Request( + mockserver.url(f"/{url}", is_secure=self.is_secure), + meta={"download_fail_on_dataloss": False}, + ) + response = await download_request(download_handler, request) assert response.flags == ["dataloss"] - def test_download_broken_chunked_content_allow_data_loss(self): - return self.test_download_broken_content_allow_data_loss("broken-chunked") - + @pytest.mark.parametrize("url", ["broken", "broken-chunked"]) @deferred_f_from_coro_f - async def test_download_broken_content_allow_data_loss_via_setting( - self, url: str = "broken" + async def test_download_allow_data_loss_via_setting( + self, url: str, mockserver: MockServer ) -> None: crawler = get_crawler(settings_dict={"DOWNLOAD_FAIL_ON_DATALOSS": False}) download_handler = build_from_crawler(self.download_handler_cls, crawler) - request = Request(self.getURL(url)) - response = await maybe_deferred_to_future( - download_handler.download_request(request, Spider("foo")) - ) + request = Request(mockserver.url(f"/{url}", is_secure=self.is_secure)) + try: + response = await maybe_deferred_to_future( + download_handler.download_request(request, DefaultSpider()) + ) + finally: + d = download_handler.close() # type: ignore[attr-defined] + if d is not None: + await maybe_deferred_to_future(d) assert response.flags == ["dataloss"] - def test_download_broken_chunked_content_allow_data_loss_via_setting(self): - return self.test_download_broken_content_allow_data_loss_via_setting( - "broken-chunked" - ) - @deferred_f_from_coro_f - async def test_protocol(self): - request = Request(self.getURL("host"), method="GET") - response = await self.download_request(request, Spider("foo")) + async def test_protocol( + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol + ) -> None: + request = Request( + mockserver.url("/host", is_secure=self.is_secure), method="GET" + ) + response = await download_request(download_handler, request) assert response.protocol == "HTTP/1.1" class TestHttps11Base(TestHttp11Base): - scheme = "https" + is_secure = True tls_log_message = ( 'SSL connection certificate: issuer "/C=IE/O=Scrapy/CN=localhost", ' @@ -476,80 +600,71 @@ class TestHttps11Base(TestHttp11Base): ) @deferred_f_from_coro_f - async def test_tls_logging(self): + async def test_tls_logging(self, mockserver: MockServer) -> None: crawler = get_crawler( settings_dict={"DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING": True} ) download_handler = build_from_crawler(self.download_handler_cls, crawler) try: with LogCapture() as log_capture: - request = Request(self.getURL("file")) + request = Request(mockserver.url("/text", is_secure=self.is_secure)) response = await maybe_deferred_to_future( - download_handler.download_request(request, Spider("foo")) + download_handler.download_request(request, DefaultSpider()) ) - assert response.body == b"0123456789" + assert response.body == b"Works" log_capture.check_present( ("scrapy.core.downloader.tls", "DEBUG", self.tls_log_message) ) finally: - await maybe_deferred_to_future(maybeDeferred(download_handler.close)) + d = download_handler.close() # type: ignore[attr-defined] + if d is not None: + await maybe_deferred_to_future(d) -class TestSimpleHttpsBase(unittest.TestCase, ABC): +class TestSimpleHttpsBase(ABC): """Base class for special cases tested with just one simple request""" keyfile = "keys/localhost.key" certfile = "keys/localhost.crt" + host = "localhost" cipher_string: str | None = None + @pytest.fixture(scope="class") + def simple_mockserver(self) -> Generator[SimpleMockServer]: + with SimpleMockServer( + self.keyfile, self.certfile, self.cipher_string + ) as simple_mockserver: + yield simple_mockserver + + @pytest.fixture(scope="class") + def url(self, simple_mockserver: SimpleMockServer) -> str: + # need to use self.host instead of what mockserver returns + return f"https://{self.host}:{simple_mockserver.port(is_secure=True)}/file" + @property @abstractmethod def download_handler_cls(self) -> type[DownloadHandlerProtocol]: raise NotImplementedError - def setUp(self): - from twisted.internet import reactor - - self.tmpname = Path(mkdtemp()) - (self.tmpname / "file").write_bytes(b"0123456789") - r = static.File(str(self.tmpname)) - self.site = server.Site(r, timeout=None) - self.host = "localhost" - self.port = reactor.listenSSL( - 0, - self.site, - ssl_context_factory( - self.keyfile, self.certfile, cipher_string=self.cipher_string - ), - interface=self.host, - ) - self.portno = self.port.getHost().port + @async_yield_fixture + async def download_handler(self) -> AsyncGenerator[DownloadHandlerProtocol]: if self.cipher_string is not None: settings_dict = {"DOWNLOADER_CLIENT_TLS_CIPHERS": self.cipher_string} else: settings_dict = None crawler = get_crawler(settings_dict=settings_dict) - self.download_handler = build_from_crawler(self.download_handler_cls, crawler) + dh = build_from_crawler(self.download_handler_cls, crawler) - @inlineCallbacks - def tearDown(self): - yield self.port.stopListening() - if hasattr(self.download_handler, "close"): - yield self.download_handler.close() - shutil.rmtree(self.tmpname) + yield dh - def getURL(self, path): - return f"https://{self.host}:{self.portno}/{path}" - - async def download_request(self, request: Request, spider: Spider) -> Response: - return await maybe_deferred_to_future( - self.download_handler.download_request(request, spider) - ) + await close_dh(dh) @deferred_f_from_coro_f - async def test_download(self): - request = Request(self.getURL("file")) - response = await self.download_request(request, Spider("foo")) + async def test_download( + self, url: str, download_handler: DownloadHandlerProtocol + ) -> None: + request = Request(url) + response = await download_request(download_handler, request) assert response.body == b"0123456789" @@ -566,9 +681,7 @@ class TestHttpsWrongHostnameBase(TestSimpleHttpsBase): class TestHttpsInvalidDNSIdBase(TestSimpleHttpsBase): """Connect to HTTPS hosts with IP while certificate uses domain names IDs.""" - def setUp(self): - super().setUp() - self.host = "127.0.0.1" + host = "127.0.0.1" class TestHttpsInvalidDNSPatternBase(TestSimpleHttpsBase): @@ -582,9 +695,7 @@ class TestHttpsCustomCiphersBase(TestSimpleHttpsBase): cipher_string = "CAMELLIA256-SHA" -class TestHttpMockServerBase(unittest.TestCase, ABC): - """HTTP 1.1 test case with MockServer""" - +class TestHttpWithCrawlerBase(ABC): @property @abstractmethod def settings_dict(self) -> dict[str, Any] | None: @@ -592,61 +703,40 @@ class TestHttpMockServerBase(unittest.TestCase, ABC): is_secure = False - @classmethod - def setUpClass(cls): - cls.mockserver = MockServer() - cls.mockserver.__enter__() - - @classmethod - def tearDownClass(cls): - cls.mockserver.__exit__(None, None, None) - @deferred_f_from_coro_f - async def test_download_with_content_length(self): + async def test_download_with_content_length(self, mockserver: MockServer) -> None: crawler = get_crawler(SingleRequestSpider, self.settings_dict) # http://localhost:8998/partial set Content-Length to 1024, use download_maxsize= 1000 to avoid # download it await maybe_deferred_to_future( crawler.crawl( seed=Request( - url=self.mockserver.url("/partial", is_secure=self.is_secure), + url=mockserver.url("/partial", is_secure=self.is_secure), meta={"download_maxsize": 1000}, ) ) ) - failure = crawler.spider.meta["failure"] + assert crawler.spider + failure = crawler.spider.meta["failure"] # type: ignore[attr-defined] assert isinstance(failure.value, defer.CancelledError) @deferred_f_from_coro_f - async def test_download(self): + async def test_download(self, mockserver: MockServer) -> None: crawler = get_crawler(SingleRequestSpider, self.settings_dict) await maybe_deferred_to_future( crawler.crawl( - seed=Request(url=self.mockserver.url("", is_secure=self.is_secure)) + seed=Request(url=mockserver.url("", is_secure=self.is_secure)) ) ) - failure = crawler.spider.meta.get("failure") + assert crawler.spider + failure = crawler.spider.meta.get("failure") # type: ignore[attr-defined] assert failure is None - reason = crawler.spider.meta["close_reason"] + reason = crawler.spider.meta["close_reason"] # type: ignore[attr-defined] assert reason == "finished" -class UriResource(resource.Resource): - """Return the full uri that was requested""" - - def getChild(self, path, request): - return self - - def render(self, request): - # Note: this is an ugly hack for CONNECT request timeout test. - # Returning some data here fail SSL/TLS handshake - # ToDo: implement proper HTTPS proxy tests, not faking them. - if request.method != b"CONNECT": - return request.uri - return b"" - - -class TestHttpProxyBase(unittest.TestCase, ABC): +class TestHttpProxyBase(ABC): + is_secure = False expected_http_proxy_request_body = b"http://example.com" @property @@ -654,64 +744,70 @@ class TestHttpProxyBase(unittest.TestCase, ABC): def download_handler_cls(self) -> type[DownloadHandlerProtocol]: raise NotImplementedError - def setUp(self): - from twisted.internet import reactor + @pytest.fixture(scope="session") + def proxy_mockserver(self) -> Generator[ProxyEchoMockServer]: + with ProxyEchoMockServer() as proxy: + yield proxy - site = server.Site(UriResource(), timeout=None) - wrapper = WrappingFactory(site) - self.port = reactor.listenTCP(0, wrapper, interface="127.0.0.1") - self.portno = self.port.getHost().port - self.download_handler = build_from_crawler( - self.download_handler_cls, get_crawler() - ) + @async_yield_fixture + async def download_handler(self) -> AsyncGenerator[DownloadHandlerProtocol]: + dh = build_from_crawler(self.download_handler_cls, get_crawler()) - @inlineCallbacks - def tearDown(self): - yield self.port.stopListening() - if hasattr(self.download_handler, "close"): - yield self.download_handler.close() + yield dh - def getURL(self, path): - return f"http://127.0.0.1:{self.portno}/{path}" - - async def download_request(self, request: Request, spider: Spider) -> Response: - return await maybe_deferred_to_future( - self.download_handler.download_request(request, spider) - ) + await close_dh(dh) @deferred_f_from_coro_f - async def test_download_with_proxy(self): - http_proxy = self.getURL("") + async def test_download_with_proxy( + self, + proxy_mockserver: ProxyEchoMockServer, + download_handler: DownloadHandlerProtocol, + ) -> None: + http_proxy = proxy_mockserver.url("", is_secure=self.is_secure) request = Request("http://example.com", meta={"proxy": http_proxy}) - response = await self.download_request(request, Spider("foo")) + response = await download_request(download_handler, request) assert response.status == 200 assert response.url == request.url assert response.body == self.expected_http_proxy_request_body @deferred_f_from_coro_f - async def test_download_without_proxy(self): - request = Request(self.getURL("path/to/resource")) - response = await self.download_request(request, Spider("foo")) + async def test_download_without_proxy( + self, + proxy_mockserver: ProxyEchoMockServer, + download_handler: DownloadHandlerProtocol, + ) -> None: + request = Request( + proxy_mockserver.url("/path/to/resource", is_secure=self.is_secure) + ) + response = await download_request(download_handler, request) assert response.status == 200 assert response.url == request.url assert response.body == b"/path/to/resource" @deferred_f_from_coro_f - async def test_download_with_proxy_https_timeout(self): + async def test_download_with_proxy_https_timeout( + self, + proxy_mockserver: ProxyEchoMockServer, + download_handler: DownloadHandlerProtocol, + ) -> None: if NON_EXISTING_RESOLVABLE: pytest.skip("Non-existing hosts are resolvable") - http_proxy = self.getURL("") + http_proxy = proxy_mockserver.url("", is_secure=self.is_secure) domain = "https://no-such-domain.nosuch" request = Request(domain, meta={"proxy": http_proxy, "download_timeout": 0.2}) with pytest.raises(error.TimeoutError) as exc_info: - await self.download_request(request, Spider("foo")) + await download_request(download_handler, request) assert domain in exc_info.value.osError @deferred_f_from_coro_f - async def test_download_with_proxy_without_http_scheme(self): - http_proxy = self.getURL("").replace("http://", "") + async def test_download_with_proxy_without_http_scheme( + self, + proxy_mockserver: ProxyEchoMockServer, + download_handler: DownloadHandlerProtocol, + ) -> None: + http_proxy = f"{proxy_mockserver.host}:{proxy_mockserver.port()}" request = Request("http://example.com", meta={"proxy": http_proxy}) - response = await self.download_request(request, Spider("foo")) + response = await download_request(download_handler, request) assert response.status == 200 assert response.url == request.url assert response.body == self.expected_http_proxy_request_body diff --git a/tests/test_downloadermiddleware.py b/tests/test_downloadermiddleware.py index 61a5a7df5..03b941e4f 100644 --- a/tests/test_downloadermiddleware.py +++ b/tests/test_downloadermiddleware.py @@ -1,37 +1,47 @@ from __future__ import annotations import asyncio +from contextlib import asynccontextmanager from gzip import BadGzipFile +from typing import TYPE_CHECKING from unittest import mock import pytest from twisted.internet.defer import Deferred, succeed -from twisted.trial.unittest import TestCase from scrapy.core.downloader.middleware import DownloaderMiddlewareManager -from scrapy.exceptions import _InvalidOutput +from scrapy.exceptions import ScrapyDeprecationWarning, _InvalidOutput from scrapy.http import Request, Response from scrapy.spiders import Spider from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future from scrapy.utils.python import to_bytes from scrapy.utils.test import get_crawler, get_from_asyncio_queue +if TYPE_CHECKING: + from collections.abc import AsyncGenerator -class TestManagerBase(TestCase): + +class TestManagerBase: settings_dict = None - def setUp(self): - self.crawler = get_crawler(Spider, self.settings_dict) - self.spider = self.crawler._create_spider("foo") - self.mwman = DownloaderMiddlewareManager.from_crawler(self.crawler) - self.crawler.engine = self.crawler._create_engine() - return self.crawler.engine.open_spider(self.spider) - - def tearDown(self): - return self.crawler.engine.close_spider(self.spider) + # should be a fixture but async fixtures that use Futures are problematic with pytest-twisted + @asynccontextmanager + async def get_mwman(self) -> AsyncGenerator[DownloaderMiddlewareManager]: + crawler = get_crawler(Spider, self.settings_dict) + crawler.spider = crawler._create_spider("foo") + mwman = DownloaderMiddlewareManager.from_crawler(crawler) + crawler.engine = crawler._create_engine() + await crawler.engine.open_spider_async() + try: + yield mwman + finally: + await crawler.engine.close_spider_async() + @staticmethod async def _download( - self, request: Request, response: Response | None = None + mwman: DownloaderMiddlewareManager, + request: Request, + response: Response | None = None, ) -> Response | Request: """Executes downloader mw manager's download method and returns the result (Request or Response) or raises exception in case of @@ -40,12 +50,10 @@ class TestManagerBase(TestCase): if not response: response = Response(request.url) - def download_func(request: Request, spider: Spider) -> Deferred[Response]: + def download_func(request: Request) -> Deferred[Response]: return succeed(response) - return await maybe_deferred_to_future( - self.mwman.download(download_func, request, self.spider) - ) + return await maybe_deferred_to_future(mwman.download(download_func, request)) class TestDefaults(TestManagerBase): @@ -55,7 +63,8 @@ class TestDefaults(TestManagerBase): async def test_request_response(self): req = Request("http://example.com/index.html") resp = Response(req.url, status=200) - ret = await self._download(req, resp) + async with self.get_mwman() as mwman: + ret = await self._download(mwman, req, resp) assert isinstance(ret, Response), "Non-response returned" @deferred_f_from_coro_f @@ -84,7 +93,8 @@ class TestDefaults(TestManagerBase): "Location": "http://example.com/login", }, ) - ret = await self._download(req, resp) + async with self.get_mwman() as mwman: + ret = await self._download(mwman, req, resp) assert isinstance(ret, Request), f"Not redirected: {ret!r}" assert to_bytes(ret.url) == resp.headers["Location"], ( "Not redirected to location header" @@ -106,7 +116,8 @@ class TestDefaults(TestManagerBase): }, ) with pytest.raises(BadGzipFile): - await self._download(req, resp) + async with self.get_mwman() as mwman: + await self._download(mwman, req, resp) class TestResponseFromProcessRequest(TestManagerBase): @@ -114,19 +125,17 @@ class TestResponseFromProcessRequest(TestManagerBase): @deferred_f_from_coro_f async def test_download_func_not_called(self): + req = Request("http://example.com/index.html") resp = Response("http://example.com/index.html") + download_func = mock.MagicMock() class ResponseMiddleware: - def process_request(self, request, spider): + def process_request(self, request): return resp - self.mwman._add_middleware(ResponseMiddleware()) - - req = Request("http://example.com/index.html") - download_func = mock.MagicMock() - result = await maybe_deferred_to_future( - self.mwman.download(download_func, req, self.spider) - ) + async with self.get_mwman() as mwman: + mwman._add_middleware(ResponseMiddleware()) + result = await maybe_deferred_to_future(mwman.download(download_func, req)) assert result is resp assert not download_func.called @@ -136,27 +145,25 @@ class TestResponseFromProcessException(TestManagerBase): @deferred_f_from_coro_f async def test_process_response_called(self): + req = Request("http://example.com/index.html") resp = Response("http://example.com/index.html") calls = [] - def download_func(request, spider): + def download_func(request): raise ValueError("test") class ResponseMiddleware: - def process_response(self, request, response, spider): + def process_response(self, request, response): calls.append("process_response") return resp - def process_exception(self, request, exception, spider): + def process_exception(self, request, exception): calls.append("process_exception") return resp - self.mwman._add_middleware(ResponseMiddleware()) - - req = Request("http://example.com/index.html") - result = await maybe_deferred_to_future( - self.mwman.download(download_func, req, self.spider) - ) + async with self.get_mwman() as mwman: + mwman._add_middleware(ResponseMiddleware()) + result = await maybe_deferred_to_future(mwman.download(download_func, req)) assert result is resp assert calls == [ "process_exception", @@ -171,12 +178,13 @@ class TestInvalidOutput(TestManagerBase): req = Request("http://example.com/index.html") class InvalidProcessRequestMiddleware: - def process_request(self, request, spider): + def process_request(self, request): return 1 - self.mwman._add_middleware(InvalidProcessRequestMiddleware()) - with pytest.raises(_InvalidOutput): - await self._download(req) + async with self.get_mwman() as mwman: + mwman._add_middleware(InvalidProcessRequestMiddleware()) + with pytest.raises(_InvalidOutput): + await self._download(mwman, req) @deferred_f_from_coro_f async def test_invalid_process_response(self): @@ -184,12 +192,13 @@ class TestInvalidOutput(TestManagerBase): req = Request("http://example.com/index.html") class InvalidProcessResponseMiddleware: - def process_response(self, request, response, spider): + def process_response(self, request, response): return 1 - self.mwman._add_middleware(InvalidProcessResponseMiddleware()) - with pytest.raises(_InvalidOutput): - await self._download(req) + async with self.get_mwman() as mwman: + mwman._add_middleware(InvalidProcessResponseMiddleware()) + with pytest.raises(_InvalidOutput): + await self._download(mwman, req) @deferred_f_from_coro_f async def test_invalid_process_exception(self): @@ -197,15 +206,16 @@ class TestInvalidOutput(TestManagerBase): req = Request("http://example.com/index.html") class InvalidProcessExceptionMiddleware: - def process_request(self, request, spider): + def process_request(self, request): raise RuntimeError - def process_exception(self, request, exception, spider): + def process_exception(self, request, exception): return 1 - self.mwman._add_middleware(InvalidProcessExceptionMiddleware()) - with pytest.raises(_InvalidOutput): - await self._download(req) + async with self.get_mwman() as mwman: + mwman._add_middleware(InvalidProcessExceptionMiddleware()) + with pytest.raises(_InvalidOutput): + await self._download(mwman, req) class TestMiddlewareUsingDeferreds(TestManagerBase): @@ -213,65 +223,136 @@ class TestMiddlewareUsingDeferreds(TestManagerBase): @deferred_f_from_coro_f async def test_deferred(self): + req = Request("http://example.com/index.html") resp = Response("http://example.com/index.html") + download_func = mock.MagicMock() class DeferredMiddleware: def cb(self, result): return result - def process_request(self, request, spider): + def process_request(self, request): d = Deferred() d.addCallback(self.cb) d.callback(resp) return d - self.mwman._add_middleware(DeferredMiddleware()) - req = Request("http://example.com/index.html") - download_func = mock.MagicMock() - result = await maybe_deferred_to_future( - self.mwman.download(download_func, req, self.spider) - ) + async with self.get_mwman() as mwman: + mwman._add_middleware(DeferredMiddleware()) + result = await maybe_deferred_to_future(mwman.download(download_func, req)) assert result is resp assert not download_func.called -@pytest.mark.usefixtures("reactor_pytest") class TestMiddlewareUsingCoro(TestManagerBase): """Middlewares using asyncio coroutines should work""" @deferred_f_from_coro_f async def test_asyncdef(self): + req = Request("http://example.com/index.html") resp = Response("http://example.com/index.html") + download_func = mock.MagicMock() class CoroMiddleware: - async def process_request(self, request, spider): + async def process_request(self, request): await succeed(42) return resp - self.mwman._add_middleware(CoroMiddleware()) - req = Request("http://example.com/index.html") - download_func = mock.MagicMock() - result = await maybe_deferred_to_future( - self.mwman.download(download_func, req, self.spider) - ) + async with self.get_mwman() as mwman: + mwman._add_middleware(CoroMiddleware()) + result = await maybe_deferred_to_future(mwman.download(download_func, req)) assert result is resp assert not download_func.called @pytest.mark.only_asyncio @deferred_f_from_coro_f async def test_asyncdef_asyncio(self): + req = Request("http://example.com/index.html") resp = Response("http://example.com/index.html") + download_func = mock.MagicMock() class CoroMiddleware: - async def process_request(self, request, spider): + async def process_request(self, request): await asyncio.sleep(0.1) return await get_from_asyncio_queue(resp) - self.mwman._add_middleware(CoroMiddleware()) - req = Request("http://example.com/index.html") - download_func = mock.MagicMock() - result = await maybe_deferred_to_future( - self.mwman.download(download_func, req, self.spider) - ) + async with self.get_mwman() as mwman: + mwman._add_middleware(CoroMiddleware()) + result = await maybe_deferred_to_future(mwman.download(download_func, req)) + assert result is resp + assert not download_func.called + + +class TestDownloadDeprecated(TestManagerBase): + @deferred_f_from_coro_f + async def test_download_func_spider_arg(self): + req = Request("http://example.com/index.html") + resp = Response(req.url, status=200) + + def download_func(request: Request, spider: Spider) -> Deferred[Response]: + return succeed(resp) + + async with self.get_mwman() as mwman: + with pytest.warns( + ScrapyDeprecationWarning, + match="The spider argument of download_func is deprecated", + ): + ret = await maybe_deferred_to_future(mwman.download(download_func, req)) + assert isinstance(ret, Response) + + @deferred_f_from_coro_f + async def test_mwman_download_spider_arg(self): + req = Request("http://example.com/index.html") + resp = Response(req.url, status=200) + + def download_func(request: Request) -> Deferred[Response]: + return succeed(resp) + + async with self.get_mwman() as mwman: + with pytest.warns( + ScrapyDeprecationWarning, + match=r"Passing a spider argument to DownloaderMiddlewareManager.download\(\)" + r" is deprecated and the passed value is ignored.", + ): + ret = await maybe_deferred_to_future( + mwman.download(download_func, req, mwman.crawler.spider) + ) + assert isinstance(ret, Response) + + +class TestDeprecatedSpiderArg(TestManagerBase): + @deferred_f_from_coro_f + async def test_deprecated_spider_arg(self): + req = Request("http://example.com/index.html") + resp = Response("http://example.com/index.html") + download_func = mock.MagicMock() + + class DeprecatedSpiderArgMiddleware: + def process_request(self, request, spider): + 1 / 0 + + def process_response(self, request, response, spider): + return response + + def process_exception(self, request, exception, spider): + return resp + + async with self.get_mwman() as mwman: + with ( + pytest.warns( + ScrapyDeprecationWarning, + match=r"process_request\(\) requires a spider argument", + ), + pytest.warns( + ScrapyDeprecationWarning, + match=r"process_response\(\) requires a spider argument", + ), + pytest.warns( + ScrapyDeprecationWarning, + match=r"process_exception\(\) requires a spider argument", + ), + ): + mwman._add_middleware(DeprecatedSpiderArgMiddleware()) + result = await maybe_deferred_to_future(mwman.download(download_func, req)) assert result is resp assert not download_func.called diff --git a/tests/test_downloadermiddleware_cookies.py b/tests/test_downloadermiddleware_cookies.py index 8bf3a1f09..49215329e 100644 --- a/tests/test_downloadermiddleware_cookies.py +++ b/tests/test_downloadermiddleware_cookies.py @@ -8,9 +8,8 @@ from scrapy.downloadermiddlewares.defaultheaders import DefaultHeadersMiddleware from scrapy.downloadermiddlewares.redirect import RedirectMiddleware from scrapy.exceptions import NotConfigured from scrapy.http import Request, Response -from scrapy.settings import Settings -from scrapy.spiders import Spider from scrapy.utils.python import to_bytes +from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler UNSET = object() @@ -60,9 +59,10 @@ class TestCookiesMiddleware: assert split_cookies(first) == split_cookies(second), msg def setup_method(self): - self.spider = Spider("foo") - self.mw = CookiesMiddleware() - self.redirect_middleware = RedirectMiddleware(settings=Settings()) + crawler = get_crawler(DefaultSpider) + crawler.spider = crawler._create_spider() + self.mw = CookiesMiddleware.from_crawler(crawler) + self.redirect_middleware = RedirectMiddleware.from_crawler(crawler) def teardown_method(self): del self.mw @@ -70,15 +70,15 @@ class TestCookiesMiddleware: def test_basic(self): req = Request("http://scrapytest.org/") - assert self.mw.process_request(req, self.spider) is None + assert self.mw.process_request(req) is None assert "Cookie" not in req.headers headers = {"Set-Cookie": "C1=value1; path=/"} res = Response("http://scrapytest.org/", headers=headers) - assert self.mw.process_response(req, res, self.spider) is res + assert self.mw.process_response(req, res) is res req2 = Request("http://scrapytest.org/sub1/") - assert self.mw.process_request(req2, self.spider) is None + assert self.mw.process_request(req2) is None assert req2.headers.get("Cookie") == b"C1=value1" def test_setting_false_cookies_enabled(self): @@ -112,9 +112,9 @@ class TestCookiesMiddleware: res = Response( "http://scrapytest.org/", headers={"Set-Cookie": "C1=value1; path=/"} ) - mw.process_response(req, res, crawler.spider) + mw.process_response(req, res) req2 = Request("http://scrapytest.org/sub1/") - mw.process_request(req2, crawler.spider) + mw.process_request(req2) log.check( ( @@ -143,23 +143,23 @@ class TestCookiesMiddleware: res = Response( "http://scrapytest.org/", headers={"Set-Cookie": "C1=value1; path=/"} ) - mw.process_response(req, res, crawler.spider) + mw.process_response(req, res) req2 = Request("http://scrapytest.org/sub1/") - mw.process_request(req2, crawler.spider) + mw.process_request(req2) log.check() def test_do_not_break_on_non_utf8_header(self): req = Request("http://scrapytest.org/") - assert self.mw.process_request(req, self.spider) is None + assert self.mw.process_request(req) is None assert "Cookie" not in req.headers headers = {"Set-Cookie": b"C1=in\xa3valid; path=/", "Other": b"ignore\xa3me"} res = Response("http://scrapytest.org/", headers=headers) - assert self.mw.process_response(req, res, self.spider) is res + assert self.mw.process_response(req, res) is res req2 = Request("http://scrapytest.org/sub1/") - assert self.mw.process_request(req2, self.spider) is None + assert self.mw.process_request(req2) is None assert "Cookie" in req2.headers def test_dont_merge_cookies(self): @@ -167,11 +167,11 @@ class TestCookiesMiddleware: headers = {"Set-Cookie": "C1=value1; path=/"} req = Request("http://scrapytest.org/") res = Response("http://scrapytest.org/", headers=headers) - assert self.mw.process_response(req, res, self.spider) is res + assert self.mw.process_response(req, res) is res # test Cookie header is not seted to request req = Request("http://scrapytest.org/dontmerge", meta={"dont_merge_cookies": 1}) - assert self.mw.process_request(req, self.spider) is None + assert self.mw.process_request(req) is None assert "Cookie" not in req.headers # check that returned cookies are not merged back to jar @@ -179,16 +179,16 @@ class TestCookiesMiddleware: "http://scrapytest.org/dontmerge", headers={"Set-Cookie": "dont=mergeme; path=/"}, ) - assert self.mw.process_response(req, res, self.spider) is res + assert self.mw.process_response(req, res) is res # check that cookies are merged back req = Request("http://scrapytest.org/mergeme") - assert self.mw.process_request(req, self.spider) is None + assert self.mw.process_request(req) is None assert req.headers.get("Cookie") == b"C1=value1" # check that cookies are merged when dont_merge_cookies is passed as 0 req = Request("http://scrapytest.org/mergeme", meta={"dont_merge_cookies": 0}) - assert self.mw.process_request(req, self.spider) is None + assert self.mw.process_request(req) is None assert req.headers.get("Cookie") == b"C1=value1" def test_complex_cookies(self): @@ -216,11 +216,11 @@ class TestCookiesMiddleware: ] req = Request("http://scrapytest.org/", cookies=cookies) - self.mw.process_request(req, self.spider) + self.mw.process_request(req) # embed C1 and C3 for scrapytest.org/foo req = Request("http://scrapytest.org/foo") - self.mw.process_request(req, self.spider) + self.mw.process_request(req) assert req.headers.get("Cookie") in ( b"C1=value1; C3=value3", b"C3=value3; C1=value1", @@ -228,25 +228,25 @@ class TestCookiesMiddleware: # embed C2 for scrapytest.org/bar req = Request("http://scrapytest.org/bar") - self.mw.process_request(req, self.spider) + self.mw.process_request(req) assert req.headers.get("Cookie") == b"C2=value2" # embed nothing for scrapytest.org/baz req = Request("http://scrapytest.org/baz") - self.mw.process_request(req, self.spider) + self.mw.process_request(req) assert "Cookie" not in req.headers def test_merge_request_cookies(self): req = Request("http://scrapytest.org/", cookies={"galleta": "salada"}) - assert self.mw.process_request(req, self.spider) is None + assert self.mw.process_request(req) is None assert req.headers.get("Cookie") == b"galleta=salada" headers = {"Set-Cookie": "C1=value1; path=/"} res = Response("http://scrapytest.org/", headers=headers) - assert self.mw.process_response(req, res, self.spider) is res + assert self.mw.process_response(req, res) is res req2 = Request("http://scrapytest.org/sub1/") - assert self.mw.process_request(req2, self.spider) is None + assert self.mw.process_request(req2) is None self.assertCookieValEqual( req2.headers.get("Cookie"), b"C1=value1; galleta=salada" @@ -258,15 +258,15 @@ class TestCookiesMiddleware: cookies={"galleta": "salada"}, meta={"cookiejar": "store1"}, ) - assert self.mw.process_request(req, self.spider) is None + assert self.mw.process_request(req) is None assert req.headers.get("Cookie") == b"galleta=salada" headers = {"Set-Cookie": "C1=value1; path=/"} res = Response("http://scrapytest.org/", headers=headers, request=req) - assert self.mw.process_response(req, res, self.spider) is res + assert self.mw.process_response(req, res) is res req2 = Request("http://scrapytest.org/", meta=res.meta) - assert self.mw.process_request(req2, self.spider) is None + assert self.mw.process_request(req2) is None self.assertCookieValEqual( req2.headers.get("Cookie"), b"C1=value1; galleta=salada" ) @@ -276,45 +276,45 @@ class TestCookiesMiddleware: cookies={"galleta": "dulce"}, meta={"cookiejar": "store2"}, ) - assert self.mw.process_request(req3, self.spider) is None + assert self.mw.process_request(req3) is None assert req3.headers.get("Cookie") == b"galleta=dulce" headers = {"Set-Cookie": "C2=value2; path=/"} res2 = Response("http://scrapytest.org/", headers=headers, request=req3) - assert self.mw.process_response(req3, res2, self.spider) is res2 + assert self.mw.process_response(req3, res2) is res2 req4 = Request("http://scrapytest.org/", meta=res2.meta) - assert self.mw.process_request(req4, self.spider) is None + assert self.mw.process_request(req4) is None self.assertCookieValEqual( req4.headers.get("Cookie"), b"C2=value2; galleta=dulce" ) # cookies from hosts with port req5_1 = Request("http://scrapytest.org:1104/") - assert self.mw.process_request(req5_1, self.spider) is None + assert self.mw.process_request(req5_1) is None headers = {"Set-Cookie": "C1=value1; path=/"} res5_1 = Response( "http://scrapytest.org:1104/", headers=headers, request=req5_1 ) - assert self.mw.process_response(req5_1, res5_1, self.spider) is res5_1 + assert self.mw.process_response(req5_1, res5_1) is res5_1 req5_2 = Request("http://scrapytest.org:1104/some-redirected-path") - assert self.mw.process_request(req5_2, self.spider) is None + assert self.mw.process_request(req5_2) is None assert req5_2.headers.get("Cookie") == b"C1=value1" req5_3 = Request("http://scrapytest.org/some-redirected-path") - assert self.mw.process_request(req5_3, self.spider) is None + assert self.mw.process_request(req5_3) is None assert req5_3.headers.get("Cookie") == b"C1=value1" # skip cookie retrieval for not http request req6 = Request("file:///scrapy/sometempfile") - assert self.mw.process_request(req6, self.spider) is None + assert self.mw.process_request(req6) is None assert req6.headers.get("Cookie") is None def test_local_domain(self): request = Request("http://example-host/", cookies={"currencyCookie": "USD"}) - assert self.mw.process_request(request, self.spider) is None + assert self.mw.process_request(request) is None assert "Cookie" in request.headers assert request.headers["Cookie"] == b"currencyCookie=USD" @@ -324,15 +324,15 @@ class TestCookiesMiddleware: mw_default_headers = DefaultHeadersMiddleware(DEFAULT_REQUEST_HEADERS.items()) # overwrite with values from 'cookies' request argument req1 = Request("http://example.org", cookies={"default": "something"}) - assert mw_default_headers.process_request(req1, self.spider) is None - assert self.mw.process_request(req1, self.spider) is None + assert mw_default_headers.process_request(req1) is None + assert self.mw.process_request(req1) is None self.assertCookieValEqual( req1.headers["Cookie"], b"default=something; asdf=qwerty" ) # keep both req2 = Request("http://example.com", cookies={"a": "b"}) - assert mw_default_headers.process_request(req2, self.spider) is None - assert self.mw.process_request(req2, self.spider) is None + assert mw_default_headers.process_request(req2) is None + assert self.mw.process_request(req2) is None self.assertCookieValEqual( req2.headers["Cookie"], b"default=value; a=b; asdf=qwerty" ) @@ -341,13 +341,13 @@ class TestCookiesMiddleware: def test_keep_cookie_header(self): # keep only cookies from 'Cookie' request header req1 = Request("http://scrapytest.org", headers={"Cookie": "a=b; c=d"}) - assert self.mw.process_request(req1, self.spider) is None + assert self.mw.process_request(req1) is None self.assertCookieValEqual(req1.headers["Cookie"], "a=b; c=d") # keep cookies from both 'Cookie' request header and 'cookies' keyword req2 = Request( "http://scrapytest.org", headers={"Cookie": "a=b; c=d"}, cookies={"e": "f"} ) - assert self.mw.process_request(req2, self.spider) is None + assert self.mw.process_request(req2) is None self.assertCookieValEqual(req2.headers["Cookie"], "a=b; c=d; e=f") # overwrite values from 'Cookie' request header with 'cookies' keyword req3 = Request( @@ -355,40 +355,40 @@ class TestCookiesMiddleware: headers={"Cookie": "a=b; c=d"}, cookies={"a": "new", "e": "f"}, ) - assert self.mw.process_request(req3, self.spider) is None + assert self.mw.process_request(req3) is None self.assertCookieValEqual(req3.headers["Cookie"], "a=new; c=d; e=f") def test_request_cookies_encoding(self): # 1) UTF8-encoded bytes req1 = Request("http://example.org", cookies={"a": "á".encode()}) - assert self.mw.process_request(req1, self.spider) is None + assert self.mw.process_request(req1) is None self.assertCookieValEqual(req1.headers["Cookie"], b"a=\xc3\xa1") # 2) Non UTF8-encoded bytes req2 = Request("http://example.org", cookies={"a": "á".encode("latin1")}) - assert self.mw.process_request(req2, self.spider) is None + assert self.mw.process_request(req2) is None self.assertCookieValEqual(req2.headers["Cookie"], b"a=\xc3\xa1") # 3) String req3 = Request("http://example.org", cookies={"a": "á"}) - assert self.mw.process_request(req3, self.spider) is None + assert self.mw.process_request(req3) is None self.assertCookieValEqual(req3.headers["Cookie"], b"a=\xc3\xa1") @pytest.mark.xfail(reason="Cookie header is not currently being processed") def test_request_headers_cookie_encoding(self): # 1) UTF8-encoded bytes req1 = Request("http://example.org", headers={"Cookie": "a=á".encode()}) - assert self.mw.process_request(req1, self.spider) is None + assert self.mw.process_request(req1) is None self.assertCookieValEqual(req1.headers["Cookie"], b"a=\xc3\xa1") # 2) Non UTF8-encoded bytes req2 = Request("http://example.org", headers={"Cookie": "a=á".encode("latin1")}) - assert self.mw.process_request(req2, self.spider) is None + assert self.mw.process_request(req2) is None self.assertCookieValEqual(req2.headers["Cookie"], b"a=\xc3\xa1") # 3) String req3 = Request("http://example.org", headers={"Cookie": "a=á"}) - assert self.mw.process_request(req3, self.spider) is None + assert self.mw.process_request(req3) is None self.assertCookieValEqual(req3.headers["Cookie"], b"a=\xc3\xa1") def test_invalid_cookies(self): @@ -402,13 +402,13 @@ class TestCookiesMiddleware: ) as lc: cookies1 = [{"value": "bar"}, {"name": "key", "value": "value1"}] req1 = Request("http://example.org/1", cookies=cookies1) - assert self.mw.process_request(req1, self.spider) is None + assert self.mw.process_request(req1) is None cookies2 = [{"name": "foo"}, {"name": "key", "value": "value2"}] req2 = Request("http://example.org/2", cookies=cookies2) - assert self.mw.process_request(req2, self.spider) is None + assert self.mw.process_request(req2) is None cookies3 = [{"name": "foo", "value": None}, {"name": "key", "value": ""}] req3 = Request("http://example.org/3", cookies=cookies3) - assert self.mw.process_request(req3, self.spider) is None + assert self.mw.process_request(req3) is None lc.check( ( "scrapy.downloadermiddlewares.cookies", @@ -436,22 +436,22 @@ class TestCookiesMiddleware: def test_primitive_type_cookies(self): # Boolean req1 = Request("http://example.org", cookies={"a": True}) - assert self.mw.process_request(req1, self.spider) is None + assert self.mw.process_request(req1) is None self.assertCookieValEqual(req1.headers["Cookie"], b"a=True") # Float req2 = Request("http://example.org", cookies={"a": 9.5}) - assert self.mw.process_request(req2, self.spider) is None + assert self.mw.process_request(req2) is None self.assertCookieValEqual(req2.headers["Cookie"], b"a=9.5") # Integer req3 = Request("http://example.org", cookies={"a": 10}) - assert self.mw.process_request(req3, self.spider) is None + assert self.mw.process_request(req3) is None self.assertCookieValEqual(req3.headers["Cookie"], b"a=10") # String req4 = Request("http://example.org", cookies={"a": "b"}) - assert self.mw.process_request(req4, self.spider) is None + assert self.mw.process_request(req4) is None self.assertCookieValEqual(req4.headers["Cookie"], b"a=b") def _test_cookie_redirect( @@ -471,7 +471,7 @@ class TestCookiesMiddleware: target.setdefault("status", 301) request1 = Request(cookies=input_cookies, **source) - self.mw.process_request(request1, self.spider) + self.mw.process_request(request1) cookies = request1.headers.get("Cookie") assert cookies == (b"a=b" if cookies1 else None) @@ -481,16 +481,12 @@ class TestCookiesMiddleware: }, **target, ) - assert self.mw.process_response(request1, response, self.spider) == response + assert self.mw.process_response(request1, response) == response - request2 = self.redirect_middleware.process_response( - request1, - response, - self.spider, - ) + request2 = self.redirect_middleware.process_response(request1, response) assert isinstance(request2, Request) - self.mw.process_request(request2, self.spider) + self.mw.process_request(request2) cookies = request2.headers.get("Cookie") assert cookies == (b"a=b" if cookies2 else None) @@ -564,11 +560,7 @@ class TestCookiesMiddleware: **target, ) - request2 = self.redirect_middleware.process_response( - request1, - response, - self.spider, - ) + request2 = self.redirect_middleware.process_response(request1, response) assert isinstance(request2, Request) cookies = request2.headers.get("Cookie") @@ -620,12 +612,12 @@ class TestCookiesMiddleware: ] request1 = Request(url1, cookies=input_cookies) - self.mw.process_request(request1, self.spider) + self.mw.process_request(request1) cookies = request1.headers.get("Cookie") assert cookies == (b"a=b" if cookies1 else None) request2 = Request(url2) - self.mw.process_request(request2, self.spider) + self.mw.process_request(request2) cookies = request2.headers.get("Cookie") assert cookies == (b"a=b" if cookies2 else None) @@ -674,7 +666,7 @@ class TestCookiesMiddleware: cookies, ): request1 = Request(url1) - self.mw.process_request(request1, self.spider) + self.mw.process_request(request1) input_cookies = [ { @@ -688,10 +680,10 @@ class TestCookiesMiddleware: "Set-Cookie": _cookies_to_set_cookie_list(input_cookies), } response = Response(url1, status=200, headers=headers) - assert self.mw.process_response(request1, response, self.spider) == response + assert self.mw.process_response(request1, response) == response request2 = Request(url2) - self.mw.process_request(request2, self.spider) + self.mw.process_request(request2) actual_cookies = request2.headers.get("Cookie") assert actual_cookies == (b"a=b" if cookies else None) @@ -743,7 +735,7 @@ class TestCookiesMiddleware: input_cookies = [{"name": "a", "value": "b", **cookie_kwargs}] request1 = Request(f"{from_scheme}://a.example", cookies=input_cookies) - self.mw.process_request(request1, self.spider) + self.mw.process_request(request1) cookies = request1.headers.get("Cookie") assert cookies == (b"a=b" if cookies1 else None) @@ -752,18 +744,14 @@ class TestCookiesMiddleware: headers={"Location": f"{to_scheme}://a.example"}, status=301, ) - assert self.mw.process_response(request1, response, self.spider) == response + assert self.mw.process_response(request1, response) == response - request2 = self.redirect_middleware.process_response( - request1, - response, - self.spider, - ) + request2 = self.redirect_middleware.process_response(request1, response) assert isinstance(request2, Request) cookies = request2.headers.get("Cookie") assert cookies == (b"a=b" if cookies2 else None) - self.mw.process_request(request2, self.spider) + self.mw.process_request(request2) cookies = request2.headers.get("Cookie") assert cookies == (b"a=b" if cookies3 else None) diff --git a/tests/test_downloadermiddleware_defaultheaders.py b/tests/test_downloadermiddleware_defaultheaders.py index 5716e3631..e697de9b7 100644 --- a/tests/test_downloadermiddleware_defaultheaders.py +++ b/tests/test_downloadermiddleware_defaultheaders.py @@ -6,28 +6,27 @@ from scrapy.utils.test import get_crawler class TestDefaultHeadersMiddleware: - def get_defaults_spider_mw(self): + def get_defaults_mw(self): crawler = get_crawler(Spider) - spider = crawler._create_spider("foo") defaults = { to_bytes(k): [to_bytes(v)] for k, v in crawler.settings.get("DEFAULT_REQUEST_HEADERS").items() } - return defaults, spider, DefaultHeadersMiddleware.from_crawler(crawler) + return defaults, DefaultHeadersMiddleware.from_crawler(crawler) def test_process_request(self): - defaults, spider, mw = self.get_defaults_spider_mw() + defaults, mw = self.get_defaults_mw() req = Request("http://www.scrapytest.org") - mw.process_request(req, spider) + mw.process_request(req) assert req.headers == defaults def test_update_headers(self): - defaults, spider, mw = self.get_defaults_spider_mw() + defaults, mw = self.get_defaults_mw() headers = {"Accept-Language": ["es"], "Test-Header": ["test"]} bytes_headers = {b"Accept-Language": [b"es"], b"Test-Header": [b"test"]} req = Request("http://www.scrapytest.org", headers=headers) assert req.headers == bytes_headers - mw.process_request(req, spider) + mw.process_request(req) defaults.update(bytes_headers) assert req.headers == defaults diff --git a/tests/test_downloadermiddleware_downloadtimeout.py b/tests/test_downloadermiddleware_downloadtimeout.py index 31323c8fa..3707cee18 100644 --- a/tests/test_downloadermiddleware_downloadtimeout.py +++ b/tests/test_downloadermiddleware_downloadtimeout.py @@ -14,20 +14,20 @@ class TestDownloadTimeoutMiddleware: def test_default_download_timeout(self): req, spider, mw = self.get_request_spider_mw() mw.spider_opened(spider) - assert mw.process_request(req, spider) is None + assert mw.process_request(req) is None assert req.meta.get("download_timeout") == 180 def test_string_download_timeout(self): req, spider, mw = self.get_request_spider_mw({"DOWNLOAD_TIMEOUT": "20.1"}) mw.spider_opened(spider) - assert mw.process_request(req, spider) is None + assert mw.process_request(req) is None assert req.meta.get("download_timeout") == 20.1 def test_spider_has_download_timeout(self): req, spider, mw = self.get_request_spider_mw() spider.download_timeout = 2 mw.spider_opened(spider) - assert mw.process_request(req, spider) is None + assert mw.process_request(req) is None assert req.meta.get("download_timeout") == 2 def test_request_has_download_timeout(self): @@ -35,5 +35,5 @@ class TestDownloadTimeoutMiddleware: spider.download_timeout = 2 mw.spider_opened(spider) req.meta["download_timeout"] = 1 - assert mw.process_request(req, spider) is None + assert mw.process_request(req) is None assert req.meta.get("download_timeout") == 1 diff --git a/tests/test_downloadermiddleware_httpauth.py b/tests/test_downloadermiddleware_httpauth.py index 9154e1850..522a3002f 100644 --- a/tests/test_downloadermiddleware_httpauth.py +++ b/tests/test_downloadermiddleware_httpauth.py @@ -36,48 +36,48 @@ class TestHttpAuthMiddlewareLegacy: class TestHttpAuthMiddleware: def setup_method(self): self.mw = HttpAuthMiddleware() - self.spider = DomainSpider("foo") - self.mw.spider_opened(self.spider) + spider = DomainSpider("foo") + self.mw.spider_opened(spider) def teardown_method(self): del self.mw def test_no_auth(self): req = Request("http://example-noauth.com/") - assert self.mw.process_request(req, self.spider) is None + assert self.mw.process_request(req) is None assert "Authorization" not in req.headers def test_auth_domain(self): req = Request("http://example.com/") - assert self.mw.process_request(req, self.spider) is None + assert self.mw.process_request(req) is None assert req.headers["Authorization"] == basic_auth_header("foo", "bar") def test_auth_subdomain(self): req = Request("http://foo.example.com/") - assert self.mw.process_request(req, self.spider) is None + assert self.mw.process_request(req) is None assert req.headers["Authorization"] == basic_auth_header("foo", "bar") def test_auth_already_set(self): req = Request("http://example.com/", headers={"Authorization": "Digest 123"}) - assert self.mw.process_request(req, self.spider) is None + assert self.mw.process_request(req) is None assert req.headers["Authorization"] == b"Digest 123" class TestHttpAuthAnyMiddleware: def setup_method(self): self.mw = HttpAuthMiddleware() - self.spider = AnyDomainSpider("foo") - self.mw.spider_opened(self.spider) + spider = AnyDomainSpider("foo") + self.mw.spider_opened(spider) def teardown_method(self): del self.mw def test_auth(self): req = Request("http://example.com/") - assert self.mw.process_request(req, self.spider) is None + assert self.mw.process_request(req) is None assert req.headers["Authorization"] == basic_auth_header("foo", "bar") def test_auth_already_set(self): req = Request("http://example.com/", headers={"Authorization": "Digest 123"}) - assert self.mw.process_request(req, self.spider) is None + assert self.mw.process_request(req) is None assert req.headers["Authorization"] == b"Digest 123" diff --git a/tests/test_downloadermiddleware_httpcache.py b/tests/test_downloadermiddleware_httpcache.py index 02f4f488e..e94591038 100644 --- a/tests/test_downloadermiddleware_httpcache.py +++ b/tests/test_downloadermiddleware_httpcache.py @@ -1,28 +1,36 @@ +from __future__ import annotations + import email.utils import shutil import tempfile import time from contextlib import contextmanager +from typing import TYPE_CHECKING, Any import pytest from scrapy.downloadermiddlewares.httpcache import HttpCacheMiddleware from scrapy.exceptions import IgnoreRequest from scrapy.http import HtmlResponse, Request, Response -from scrapy.settings import Settings from scrapy.spiders import Spider from scrapy.utils.test import get_crawler +if TYPE_CHECKING: + from collections.abc import Generator + + from scrapy.crawler import Crawler + class TestBase: """Base class with common setup and helper methods.""" + policy_class: str + storage_class: str + def setup_method(self): self.yesterday = email.utils.formatdate(time.time() - 86400) self.today = email.utils.formatdate() self.tomorrow = email.utils.formatdate(time.time() + 86400) - self.crawler = get_crawler(Spider) - self.spider = self.crawler._create_spider("example.com") self.tmpdir = tempfile.mkdtemp() self.request = Request("http://www.example.com", headers={"User-Agent": "test"}) self.response = Response( @@ -31,13 +39,11 @@ class TestBase: body=b"test body", status=202, ) - self.crawler.stats.open_spider(self.spider) def teardown_method(self): - self.crawler.stats.close_spider(self.spider, "") shutil.rmtree(self.tmpdir) - def _get_settings(self, **new_settings): + def _get_settings(self, **new_settings: Any) -> dict[str, Any]: settings = { "HTTPCACHE_ENABLED": True, "HTTPCACHE_DIR": self.tmpdir, @@ -47,27 +53,35 @@ class TestBase: "HTTPCACHE_STORAGE": self.storage_class, } settings.update(new_settings) - return Settings(settings) + return settings @contextmanager - def _storage(self, **new_settings): - with self._middleware(**new_settings) as mw: - yield mw.storage - - @contextmanager - def _policy(self, **new_settings): - with self._middleware(**new_settings) as mw: - yield mw.policy - - @contextmanager - def _middleware(self, **new_settings): + def _get_crawler(self, **new_settings: Any) -> Generator[Crawler]: settings = self._get_settings(**new_settings) - mw = HttpCacheMiddleware(settings, self.crawler.stats) - mw.spider_opened(self.spider) + crawler = get_crawler(Spider, settings) + crawler.spider = crawler._create_spider("example.com") + assert crawler.stats + crawler.stats.open_spider() try: - yield mw + yield crawler finally: - mw.spider_closed(self.spider) + crawler.stats.close_spider() + + @contextmanager + def _storage(self, **new_settings: Any): + with self._middleware(**new_settings) as mw: + yield mw.storage, mw.crawler + + @contextmanager + def _middleware(self, **new_settings: Any) -> Generator[HttpCacheMiddleware]: + with self._get_crawler(**new_settings) as crawler: + assert crawler.spider + mw = HttpCacheMiddleware.from_crawler(crawler) + mw.spider_opened(crawler.spider) + try: + yield mw + finally: + mw.spider_closed(crawler.spider) def assertEqualResponse(self, response1, response2): assert response1.url == response2.url @@ -94,37 +108,37 @@ class StorageTestMixin: """Mixin containing storage-specific test methods.""" def test_storage(self): - with self._storage() as storage: + with self._storage() as (storage, crawler): request2 = self.request.copy() - assert storage.retrieve_response(self.spider, request2) is None + assert storage.retrieve_response(crawler.spider, request2) is None - storage.store_response(self.spider, self.request, self.response) - response2 = storage.retrieve_response(self.spider, request2) + storage.store_response(crawler.spider, self.request, self.response) + response2 = storage.retrieve_response(crawler.spider, request2) assert isinstance(response2, HtmlResponse) # content-type header self.assertEqualResponse(self.response, response2) time.sleep(2) # wait for cache to expire - assert storage.retrieve_response(self.spider, request2) is None + assert storage.retrieve_response(crawler.spider, request2) is None def test_storage_never_expire(self): - with self._storage(HTTPCACHE_EXPIRATION_SECS=0) as storage: - assert storage.retrieve_response(self.spider, self.request) is None - storage.store_response(self.spider, self.request, self.response) + with self._storage(HTTPCACHE_EXPIRATION_SECS=0) as (storage, crawler): + assert storage.retrieve_response(crawler.spider, self.request) is None + storage.store_response(crawler.spider, self.request, self.response) time.sleep(0.5) # give the chance to expire - assert storage.retrieve_response(self.spider, self.request) + assert storage.retrieve_response(crawler.spider, self.request) 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""" - with self._storage() as storage: - assert storage.retrieve_response(self.spider, self.request) is None + with self._storage() as (storage, crawler): + assert storage.retrieve_response(crawler.spider, self.request) is None response = Response( "http://www.example.com", body=b"\n.", status=202, ) - storage.store_response(self.spider, self.request, response) - cached_response = storage.retrieve_response(self.spider, self.request) + storage.store_response(crawler.spider, self.request, response) + cached_response = storage.retrieve_response(crawler.spider, self.request) assert isinstance(cached_response, HtmlResponse) self.assertEqualResponse(response, cached_response) @@ -135,15 +149,15 @@ class PolicyTestMixin: def test_dont_cache(self): with self._middleware() as mw: self.request.meta["dont_cache"] = True - mw.process_response(self.request, self.response, self.spider) - assert mw.storage.retrieve_response(self.spider, self.request) is None + mw.process_response(self.request, self.response) + assert mw.storage.retrieve_response(mw.crawler.spider, self.request) is None with self._middleware() as mw: self.request.meta["dont_cache"] = False - mw.process_response(self.request, self.response, self.spider) + mw.process_response(self.request, self.response) if mw.policy.should_cache_response(self.response, self.request): assert isinstance( - mw.storage.retrieve_response(self.spider, self.request), + mw.storage.retrieve_response(mw.crawler.spider, self.request), self.response.__class__, ) @@ -153,9 +167,9 @@ class DummyPolicyTestMixin(PolicyTestMixin): def test_middleware(self): with self._middleware() as mw: - assert mw.process_request(self.request, self.spider) is None - mw.process_response(self.request, self.response, self.spider) - response = mw.process_request(self.request, self.spider) + assert mw.process_request(self.request) is None + mw.process_response(self.request, self.response) + response = mw.process_request(self.request) assert isinstance(response, HtmlResponse) self.assertEqualResponse(self.response, response) assert "cached" in response.flags @@ -164,9 +178,9 @@ class DummyPolicyTestMixin(PolicyTestMixin): with self._middleware() as mw: req = Request("http://host.com/path") res = Response("http://host2.net/test.html") - assert mw.process_request(req, self.spider) is None - mw.process_response(req, res, self.spider) - cached = mw.process_request(req, self.spider) + assert mw.process_request(req) is None + mw.process_response(req, res) + cached = mw.process_request(req) assert isinstance(cached, Response) self.assertEqualResponse(res, cached) assert "cached" in cached.flags @@ -174,9 +188,9 @@ class DummyPolicyTestMixin(PolicyTestMixin): def test_middleware_ignore_missing(self): with self._middleware(HTTPCACHE_IGNORE_MISSING=True) as mw: with pytest.raises(IgnoreRequest): - mw.process_request(self.request, self.spider) - mw.process_response(self.request, self.response, self.spider) - response = mw.process_request(self.request, self.spider) + mw.process_request(self.request) + mw.process_response(self.request, self.response) + response = mw.process_request(self.request) assert isinstance(response, HtmlResponse) self.assertEqualResponse(self.response, response) assert "cached" in response.flags @@ -185,10 +199,10 @@ class DummyPolicyTestMixin(PolicyTestMixin): # http responses are cached by default req, res = Request("http://test.com/"), Response("http://test.com/") with self._middleware() as mw: - assert mw.process_request(req, self.spider) is None - mw.process_response(req, res, self.spider) + assert mw.process_request(req) is None + mw.process_response(req, res) - cached = mw.process_request(req, self.spider) + cached = mw.process_request(req) assert isinstance(cached, Response), type(cached) self.assertEqualResponse(res, cached) assert "cached" in cached.flags @@ -196,19 +210,19 @@ class DummyPolicyTestMixin(PolicyTestMixin): # file response is not cached by default req, res = Request("file:///tmp/t.txt"), Response("file:///tmp/t.txt") with self._middleware() as mw: - assert mw.process_request(req, self.spider) is None - mw.process_response(req, res, self.spider) + assert mw.process_request(req) is None + mw.process_response(req, res) - assert mw.storage.retrieve_response(self.spider, req) is None - assert mw.process_request(req, self.spider) is None + assert mw.storage.retrieve_response(mw.crawler.spider, req) is None + assert mw.process_request(req) is None # s3 scheme response is cached by default req, res = Request("s3://bucket/key"), Response("http://bucket/key") with self._middleware() as mw: - assert mw.process_request(req, self.spider) is None - mw.process_response(req, res, self.spider) + assert mw.process_request(req) is None + mw.process_response(req, res) - cached = mw.process_request(req, self.spider) + cached = mw.process_request(req) assert isinstance(cached, Response), type(cached) self.assertEqualResponse(res, cached) assert "cached" in cached.flags @@ -216,25 +230,25 @@ class DummyPolicyTestMixin(PolicyTestMixin): # ignore s3 scheme req, res = Request("s3://bucket/key2"), Response("http://bucket/key2") with self._middleware(HTTPCACHE_IGNORE_SCHEMES=["s3"]) as mw: - assert mw.process_request(req, self.spider) is None - mw.process_response(req, res, self.spider) + assert mw.process_request(req) is None + mw.process_response(req, res) - assert mw.storage.retrieve_response(self.spider, req) is None - assert mw.process_request(req, self.spider) is None + assert mw.storage.retrieve_response(mw.crawler.spider, req) is None + assert mw.process_request(req) is None def test_middleware_ignore_http_codes(self): # test response is not cached with self._middleware(HTTPCACHE_IGNORE_HTTP_CODES=[202]) as mw: - assert mw.process_request(self.request, self.spider) is None - mw.process_response(self.request, self.response, self.spider) + assert mw.process_request(self.request) is None + mw.process_response(self.request, self.response) - assert mw.storage.retrieve_response(self.spider, self.request) is None - assert mw.process_request(self.request, self.spider) is None + assert mw.storage.retrieve_response(mw.crawler.spider, self.request) is None + assert mw.process_request(self.request) is None # test response is cached with self._middleware(HTTPCACHE_IGNORE_HTTP_CODES=[203]) as mw: - mw.process_response(self.request, self.response, self.spider) - response = mw.process_request(self.request, self.spider) + mw.process_response(self.request, self.response) + response = mw.process_request(self.request) assert isinstance(response, HtmlResponse) self.assertEqualResponse(self.response, response) assert "cached" in response.flags @@ -243,14 +257,18 @@ class DummyPolicyTestMixin(PolicyTestMixin): class RFC2616PolicyTestMixin(PolicyTestMixin): """Mixin containing RFC2616 policy specific test methods.""" - def _process_requestresponse(self, mw, request, response): + @staticmethod + def _process_requestresponse( + mw: HttpCacheMiddleware, request: Request, response: Response | None + ) -> Response | Request: result = None try: - result = mw.process_request(request, self.spider) + result = mw.process_request(request) if result: assert isinstance(result, (Request, Response)) return result - result = mw.process_response(request, response, self.spider) + assert response is not None + result = mw.process_response(request, response) assert isinstance(result, Response) return result except Exception: @@ -270,11 +288,11 @@ class RFC2616PolicyTestMixin(PolicyTestMixin): # response for a request with no-store must not be cached res1 = self._process_requestresponse(mw, req1, res0) self.assertEqualResponse(res1, res0) - assert mw.storage.retrieve_response(self.spider, req1) is None + assert mw.storage.retrieve_response(mw.crawler.spider, req1) is None # Re-do request without no-store and expect it to be cached res2 = self._process_requestresponse(mw, req0, res0) assert "cached" not in res2.flags - res3 = mw.process_request(req0, self.spider) + res3 = mw.process_request(req0) assert "cached" in res3.flags self.assertEqualResponse(res2, res3) # request with no-cache directive must not return cached response @@ -330,7 +348,7 @@ class RFC2616PolicyTestMixin(PolicyTestMixin): ) self.assertEqualResponse(res1, res0) self.assertEqualResponse(res2, res0) - resc = mw.storage.retrieve_response(self.spider, req0) + resc = mw.storage.retrieve_response(mw.crawler.spider, req0) if shouldcache: self.assertEqualResponse(resc, res1) assert "cached" in res2.flags @@ -354,7 +372,7 @@ class RFC2616PolicyTestMixin(PolicyTestMixin): ) self.assertEqualResponse(res1, res0) self.assertEqualResponse(res2, res0) - resc = mw.storage.retrieve_response(self.spider, req0) + resc = mw.storage.retrieve_response(mw.crawler.spider, req0) if shouldcache: self.assertEqualResponse(resc, res1) assert "cached" in res2.flags @@ -421,7 +439,7 @@ class RFC2616PolicyTestMixin(PolicyTestMixin): # validate cached response if request max-age set as 0 req1 = req0.replace(headers={"Cache-Control": "max-age=0"}) res304 = res0.replace(status=304) - assert mw.process_request(req1, self.spider) is None + assert mw.process_request(req1) is None res3 = self._process_requestresponse(mw, req1, res304) self.assertEqualResponse(res1, res3) assert "cached" in res3.flags @@ -513,14 +531,14 @@ class RFC2616PolicyTestMixin(PolicyTestMixin): self._process_requestresponse(mw, req0, res0) for e in mw.DOWNLOAD_EXCEPTIONS: # Simulate encountering an error on download attempts - assert mw.process_request(req0, self.spider) is None - res1 = mw.process_exception(req0, e("foo"), self.spider) + assert mw.process_request(req0) is None + res1 = mw.process_exception(req0, e("foo")) # Use cached response as recovery assert "cached" in res1.flags self.assertEqualResponse(res0, res1) # Do not use cached response for unhandled exceptions - mw.process_request(req0, self.spider) - assert mw.process_exception(req0, Exception("foo"), self.spider) is None + mw.process_request(req0) + assert mw.process_exception(req0, Exception("foo")) is None def test_ignore_response_cache_controls(self): sampledata = [ @@ -578,17 +596,17 @@ class TestDbmStorageWithRFC2616Policy( class TestDbmStorageWithCustomDbmModule(TestDbmStorageWithDummyPolicy): dbm_module = "tests.mocks.dummydbm" - def _get_settings(self, **new_settings): + def _get_settings(self, **new_settings) -> dict[str, Any]: new_settings.setdefault("HTTPCACHE_DBM_MODULE", self.dbm_module) return super()._get_settings(**new_settings) def test_custom_dbm_module_loaded(self): # make sure our dbm module has been loaded - with self._storage() as storage: + with self._storage() as (storage, _): assert storage.dbmodule.__name__ == self.dbm_module class TestFilesystemStorageGzipWithDummyPolicy(TestFilesystemStorageWithDummyPolicy): - def _get_settings(self, **new_settings): + def _get_settings(self, **new_settings) -> dict[str, Any]: new_settings.setdefault("HTTPCACHE_GZIP", True) return super()._get_settings(**new_settings) diff --git a/tests/test_downloadermiddleware_httpcompression.py b/tests/test_downloadermiddleware_httpcompression.py index 3c26b242f..bb7fcd6c7 100644 --- a/tests/test_downloadermiddleware_httpcompression.py +++ b/tests/test_downloadermiddleware_httpcompression.py @@ -50,12 +50,32 @@ FORMAT = { } +def _skip_if_no_br() -> None: + try: + try: + import brotli # noqa: PLC0415 + + brotli.Decompressor.can_accept_more_data + except (ImportError, AttributeError): + import brotlicffi # noqa: PLC0415 + + brotlicffi.Decompressor.can_accept_more_data + except (ImportError, AttributeError): + pytest.skip("no brotli support") + + +def _skip_if_no_zstd() -> None: + try: + import zstandard # noqa: F401,PLC0415 + except ImportError: + pytest.skip("no zstd support (zstandard)") + + class TestHttpCompression: def setup_method(self): self.crawler = get_crawler(Spider) - self.spider = self.crawler._create_spider("scrapytest.org") self.mw = HttpCompressionMiddleware.from_crawler(self.crawler) - self.crawler.stats.open_spider(self.spider) + self.crawler.stats.open_spider() def _getresponse(self, coding): if coding not in FORMAT: @@ -80,8 +100,8 @@ class TestHttpCompression: return response def assertStatsEqual(self, key, value): - assert self.crawler.stats.get_value(key, spider=self.spider) == value, str( - self.crawler.stats.get_stats(self.spider) + assert self.crawler.stats.get_value(key) == value, str( + self.crawler.stats.get_stats() ) def test_setting_false_compression_enabled(self): @@ -107,7 +127,7 @@ class TestHttpCompression: def test_process_request(self): request = Request("http://scrapytest.org") assert "Accept-Encoding" not in request.headers - self.mw.process_request(request, self.spider) + self.mw.process_request(request) assert request.headers.get("Accept-Encoding") == b", ".join(ACCEPTED_ENCODINGS) def test_process_response_gzip(self): @@ -115,7 +135,7 @@ class TestHttpCompression: request = response.request assert response.headers["Content-Encoding"] == b"gzip" - newresponse = self.mw.process_response(request, response, self.spider) + newresponse = self.mw.process_response(request, response) assert newresponse is not response assert newresponse.body.startswith(b"= 1.2.0 to decode 'br'." ), ), ) @@ -176,10 +191,8 @@ class TestHttpCompression: assert newresponse.headers.getlist("Content-Encoding") == [b"br"] def test_process_response_zstd(self): - try: - import zstandard # noqa: F401 - except ImportError: - pytest.skip("no zstd support (zstandard)") + _skip_if_no_zstd() + raw_content = None for check_key in FORMAT: if not check_key.startswith("zstd-"): @@ -187,7 +200,7 @@ class TestHttpCompression: response = self._getresponse(check_key) request = response.request assert response.headers["Content-Encoding"] == b"zstd" - newresponse = self.mw.process_response(request, response, self.spider) + newresponse = self.mw.process_response(request, response) if raw_content is None: raw_content = newresponse.body else: @@ -198,7 +211,7 @@ class TestHttpCompression: def test_process_response_zstd_unsupported(self): try: - import zstandard # noqa: F401 + import zstandard # noqa: F401,PLC0415 pytest.skip("Requires not having zstandard support") except ImportError: @@ -211,7 +224,7 @@ class TestHttpCompression: propagate=False, level=WARNING, ) as log: - newresponse = self.mw.process_response(request, response, self.spider) + newresponse = self.mw.process_response(request, response) log.check( ( "scrapy.downloadermiddlewares.httpcompression", @@ -231,7 +244,7 @@ class TestHttpCompression: request = response.request assert response.headers["Content-Encoding"] == b"deflate" - newresponse = self.mw.process_response(request, response, self.spider) + newresponse = self.mw.process_response(request, response) assert newresponse is not response assert newresponse.body.startswith(b" req.priority def test_dont_redirect(self): @@ -28,7 +29,7 @@ class Base: req = Request(url, meta={"dont_redirect": True}) rsp = self.get_response(req, url2) - r = self.mw.process_response(req, rsp, self.spider) + r = self.mw.process_response(req, rsp) assert isinstance(r, Response) assert r is rsp @@ -36,7 +37,7 @@ class Base: req = Request(url, meta={"dont_redirect": False}) rsp = self.get_response(req, url2) - r = self.mw.process_response(req, rsp, self.spider) + r = self.mw.process_response(req, rsp) assert isinstance(r, Request) def test_post(self): @@ -50,7 +51,7 @@ class Base: ) rsp = self.get_response(req, url2) - req2 = self.mw.process_response(req, rsp, self.spider) + req2 = self.mw.process_response(req, rsp) assert isinstance(req2, Request) assert req2.url == url2 assert req2.method == "GET" @@ -67,29 +68,29 @@ class Base: req = Request("http://scrapytest.org/302") rsp = self.get_response(req, "/redirected") - req = self.mw.process_response(req, rsp, self.spider) + req = self.mw.process_response(req, rsp) assert isinstance(req, Request) assert "redirect_times" in req.meta assert req.meta["redirect_times"] == 1 with pytest.raises(IgnoreRequest): - self.mw.process_response(req, rsp, self.spider) + self.mw.process_response(req, rsp) def test_ttl(self): self.mw.max_redirect_times = 100 req = Request("http://scrapytest.org/302", meta={"redirect_ttl": 1}) rsp = self.get_response(req, "/a") - req = self.mw.process_response(req, rsp, self.spider) + req = self.mw.process_response(req, rsp) assert isinstance(req, Request) with pytest.raises(IgnoreRequest): - self.mw.process_response(req, rsp, self.spider) + self.mw.process_response(req, rsp) def test_redirect_urls(self): req1 = Request("http://scrapytest.org/first") rsp1 = self.get_response(req1, "/redirected") - req2 = self.mw.process_response(req1, rsp1, self.spider) + req2 = self.mw.process_response(req1, rsp1) rsp2 = self.get_response(req1, "/redirected2") - req3 = self.mw.process_response(req2, rsp2, self.spider) + req3 = self.mw.process_response(req2, rsp2) assert req2.url == "http://scrapytest.org/redirected" assert req2.meta["redirect_urls"] == ["http://scrapytest.org/first"] @@ -102,9 +103,9 @@ class Base: def test_redirect_reasons(self): req1 = Request("http://scrapytest.org/first") rsp1 = self.get_response(req1, "/redirected1") - req2 = self.mw.process_response(req1, rsp1, self.spider) + req2 = self.mw.process_response(req1, rsp1) rsp2 = self.get_response(req2, "/redirected2") - req3 = self.mw.process_response(req2, rsp2, self.spider) + req3 = self.mw.process_response(req2, rsp2) assert req2.meta["redirect_reasons"] == [self.reason] assert req3.meta["redirect_reasons"] == [self.reason, self.reason] @@ -124,7 +125,7 @@ class Base: original_request, "https://example.com/a" ) internal_redirect_request = self.mw.process_response( - original_request, internal_response, self.spider + original_request, internal_response ) assert isinstance(internal_redirect_request, Request) assert original_request.headers == internal_redirect_request.headers @@ -137,7 +138,7 @@ class Base: ) http_response = self.get_response(http_request, "http://example.com/a") http_redirect_request = self.mw.process_response( - http_request, http_response, self.spider + http_request, http_response ) assert isinstance(http_redirect_request, Request) assert http_request.headers == http_redirect_request.headers @@ -148,7 +149,7 @@ class Base: original_request, "https://example.com:443/a" ) to_explicit_port_redirect_request = self.mw.process_response( - original_request, to_explicit_port_response, self.spider + original_request, to_explicit_port_response ) assert isinstance(to_explicit_port_redirect_request, Request) assert original_request.headers == to_explicit_port_redirect_request.headers @@ -159,7 +160,7 @@ class Base: original_request, "https://example.com/a" ) to_implicit_port_redirect_request = self.mw.process_response( - original_request, to_implicit_port_response, self.spider + original_request, to_implicit_port_response ) assert isinstance(to_implicit_port_redirect_request, Request) assert original_request.headers == to_implicit_port_redirect_request.headers @@ -171,7 +172,7 @@ class Base: original_request, "https://example.com:8080/a" ) different_port_redirect_request = self.mw.process_response( - original_request, different_port_response, self.spider + original_request, different_port_response ) assert isinstance(different_port_redirect_request, Request) assert { @@ -184,7 +185,7 @@ class Base: original_request, "https://example.org/a" ) external_redirect_request = self.mw.process_response( - original_request, external_response, self.spider + original_request, external_response ) assert isinstance(external_redirect_request, Request) assert safe_headers == external_redirect_request.headers.to_unicode_dict() @@ -194,7 +195,7 @@ class Base: # domain remains the same. upgrade_response = self.get_response(http_request, "https://example.com/a") upgrade_redirect_request = self.mw.process_response( - http_request, upgrade_response, self.spider + http_request, upgrade_response ) assert isinstance(upgrade_redirect_request, Request) assert { @@ -215,7 +216,7 @@ class Base: original_request, "http://example.com/a" ) downgrade_redirect_request = self.mw.process_response( - original_request, downgrade_response, self.spider + original_request, downgrade_response ) assert isinstance(downgrade_redirect_request, Request) assert safe_headers == downgrade_redirect_request.headers.to_unicode_dict() @@ -227,36 +228,35 @@ class Base: meta = {"proxy": "https://a:@a.example"} request1 = Request("http://example.com", meta=meta) - spider = None - proxy_mw.process_request(request1, spider) + proxy_mw.process_request(request1) assert request1.headers["Proxy-Authorization"] == b"Basic YTo=" assert request1.meta["_auth_proxy"] == "https://a.example" assert request1.meta["proxy"] == "https://a.example" response1 = self.get_response(request1, "http://example.com") - request2 = redirect_mw.process_response(request1, response1, spider) + request2 = redirect_mw.process_response(request1, response1) assert isinstance(request2, Request) assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" assert request2.meta["_auth_proxy"] == "https://a.example" assert request2.meta["proxy"] == "https://a.example" - proxy_mw.process_request(request2, spider) + proxy_mw.process_request(request2) assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" assert request2.meta["_auth_proxy"] == "https://a.example" assert request2.meta["proxy"] == "https://a.example" response2 = self.get_response(request2, "http://example.com") - request3 = redirect_mw.process_response(request2, response2, spider) + request3 = redirect_mw.process_response(request2, response2) assert isinstance(request3, Request) assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" assert request3.meta["_auth_proxy"] == "https://a.example" assert request3.meta["proxy"] == "https://a.example" - proxy_mw.process_request(request3, spider) + proxy_mw.process_request(request3) assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" assert request3.meta["_auth_proxy"] == "https://a.example" @@ -269,36 +269,35 @@ class Base: meta = {"proxy": "https://a:@a.example"} request1 = Request("http://example.com", meta=meta) - spider = None - proxy_mw.process_request(request1, spider) + proxy_mw.process_request(request1) assert request1.headers["Proxy-Authorization"] == b"Basic YTo=" assert request1.meta["_auth_proxy"] == "https://a.example" assert request1.meta["proxy"] == "https://a.example" response1 = self.get_response(request1, "/a") - request2 = redirect_mw.process_response(request1, response1, spider) + request2 = redirect_mw.process_response(request1, response1) assert isinstance(request2, Request) assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" assert request2.meta["_auth_proxy"] == "https://a.example" assert request2.meta["proxy"] == "https://a.example" - proxy_mw.process_request(request2, spider) + proxy_mw.process_request(request2) assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" assert request2.meta["_auth_proxy"] == "https://a.example" assert request2.meta["proxy"] == "https://a.example" response2 = self.get_response(request2, "/a") - request3 = redirect_mw.process_response(request2, response2, spider) + request3 = redirect_mw.process_response(request2, response2) assert isinstance(request3, Request) assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" assert request3.meta["_auth_proxy"] == "https://a.example" assert request3.meta["proxy"] == "https://a.example" - proxy_mw.process_request(request3, spider) + proxy_mw.process_request(request3) assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" assert request3.meta["_auth_proxy"] == "https://a.example" @@ -311,36 +310,35 @@ class Base: meta = {"proxy": "https://a:@a.example"} request1 = Request("https://example.com", meta=meta) - spider = None - proxy_mw.process_request(request1, spider) + proxy_mw.process_request(request1) assert request1.headers["Proxy-Authorization"] == b"Basic YTo=" assert request1.meta["_auth_proxy"] == "https://a.example" assert request1.meta["proxy"] == "https://a.example" response1 = self.get_response(request1, "https://example.com") - request2 = redirect_mw.process_response(request1, response1, spider) + request2 = redirect_mw.process_response(request1, response1) assert isinstance(request2, Request) assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" assert request2.meta["_auth_proxy"] == "https://a.example" assert request2.meta["proxy"] == "https://a.example" - proxy_mw.process_request(request2, spider) + proxy_mw.process_request(request2) assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" assert request2.meta["_auth_proxy"] == "https://a.example" assert request2.meta["proxy"] == "https://a.example" response2 = self.get_response(request2, "https://example.com") - request3 = redirect_mw.process_response(request2, response2, spider) + request3 = redirect_mw.process_response(request2, response2) assert isinstance(request3, Request) assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" assert request3.meta["_auth_proxy"] == "https://a.example" assert request3.meta["proxy"] == "https://a.example" - proxy_mw.process_request(request3, spider) + proxy_mw.process_request(request3) assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" assert request3.meta["_auth_proxy"] == "https://a.example" @@ -353,36 +351,35 @@ class Base: meta = {"proxy": "https://a:@a.example"} request1 = Request("https://example.com", meta=meta) - spider = None - proxy_mw.process_request(request1, spider) + proxy_mw.process_request(request1) assert request1.headers["Proxy-Authorization"] == b"Basic YTo=" assert request1.meta["_auth_proxy"] == "https://a.example" assert request1.meta["proxy"] == "https://a.example" response1 = self.get_response(request1, "/a") - request2 = redirect_mw.process_response(request1, response1, spider) + request2 = redirect_mw.process_response(request1, response1) assert isinstance(request2, Request) assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" assert request2.meta["_auth_proxy"] == "https://a.example" assert request2.meta["proxy"] == "https://a.example" - proxy_mw.process_request(request2, spider) + proxy_mw.process_request(request2) assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" assert request2.meta["_auth_proxy"] == "https://a.example" assert request2.meta["proxy"] == "https://a.example" response2 = self.get_response(request2, "/a") - request3 = redirect_mw.process_response(request2, response2, spider) + request3 = redirect_mw.process_response(request2, response2) assert isinstance(request3, Request) assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" assert request3.meta["_auth_proxy"] == "https://a.example" assert request3.meta["proxy"] == "https://a.example" - proxy_mw.process_request(request3, spider) + proxy_mw.process_request(request3) assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" assert request3.meta["_auth_proxy"] == "https://a.example" @@ -395,36 +392,35 @@ class Base: meta = {"proxy": "https://a:@a.example"} request1 = Request("http://example.com", meta=meta) - spider = None - proxy_mw.process_request(request1, spider) + proxy_mw.process_request(request1) assert request1.headers["Proxy-Authorization"] == b"Basic YTo=" assert request1.meta["_auth_proxy"] == "https://a.example" assert request1.meta["proxy"] == "https://a.example" response1 = self.get_response(request1, "https://example.com") - request2 = redirect_mw.process_response(request1, response1, spider) + request2 = redirect_mw.process_response(request1, response1) assert isinstance(request2, Request) assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" assert request2.meta["_auth_proxy"] == "https://a.example" assert request2.meta["proxy"] == "https://a.example" - proxy_mw.process_request(request2, spider) + proxy_mw.process_request(request2) assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" assert request2.meta["_auth_proxy"] == "https://a.example" assert request2.meta["proxy"] == "https://a.example" response2 = self.get_response(request2, "http://example.com") - request3 = redirect_mw.process_response(request2, response2, spider) + request3 = redirect_mw.process_response(request2, response2) assert isinstance(request3, Request) assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" assert request3.meta["_auth_proxy"] == "https://a.example" assert request3.meta["proxy"] == "https://a.example" - proxy_mw.process_request(request3, spider) + proxy_mw.process_request(request3) assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" assert request3.meta["_auth_proxy"] == "https://a.example" @@ -437,36 +433,35 @@ class Base: meta = {"proxy": "https://a:@a.example"} request1 = Request("https://example.com", meta=meta) - spider = None - proxy_mw.process_request(request1, spider) + proxy_mw.process_request(request1) assert request1.headers["Proxy-Authorization"] == b"Basic YTo=" assert request1.meta["_auth_proxy"] == "https://a.example" assert request1.meta["proxy"] == "https://a.example" response1 = self.get_response(request1, "http://example.com") - request2 = redirect_mw.process_response(request1, response1, spider) + request2 = redirect_mw.process_response(request1, response1) assert isinstance(request2, Request) assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" assert request2.meta["_auth_proxy"] == "https://a.example" assert request2.meta["proxy"] == "https://a.example" - proxy_mw.process_request(request2, spider) + proxy_mw.process_request(request2) assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" assert request2.meta["_auth_proxy"] == "https://a.example" assert request2.meta["proxy"] == "https://a.example" response2 = self.get_response(request2, "https://example.com") - request3 = redirect_mw.process_response(request2, response2, spider) + request3 = redirect_mw.process_response(request2, response2) assert isinstance(request3, Request) assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" assert request3.meta["_auth_proxy"] == "https://a.example" assert request3.meta["proxy"] == "https://a.example" - proxy_mw.process_request(request3, spider) + proxy_mw.process_request(request3) assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" assert request3.meta["_auth_proxy"] == "https://a.example" @@ -482,36 +477,35 @@ class Base: proxy_mw = HttpProxyMiddleware.from_crawler(crawler) request1 = Request("http://example.com") - spider = None - proxy_mw.process_request(request1, spider) + proxy_mw.process_request(request1) assert request1.headers["Proxy-Authorization"] == b"Basic YTo=" assert request1.meta["_auth_proxy"] == "https://a.example" assert request1.meta["proxy"] == "https://a.example" response1 = self.get_response(request1, "http://example.com") - request2 = redirect_mw.process_response(request1, response1, spider) + request2 = redirect_mw.process_response(request1, response1) assert isinstance(request2, Request) assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" assert request2.meta["_auth_proxy"] == "https://a.example" assert request2.meta["proxy"] == "https://a.example" - proxy_mw.process_request(request2, spider) + proxy_mw.process_request(request2) assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" assert request2.meta["_auth_proxy"] == "https://a.example" assert request2.meta["proxy"] == "https://a.example" response2 = self.get_response(request2, "http://example.com") - request3 = redirect_mw.process_response(request2, response2, spider) + request3 = redirect_mw.process_response(request2, response2) assert isinstance(request3, Request) assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" assert request3.meta["_auth_proxy"] == "https://a.example" assert request3.meta["proxy"] == "https://a.example" - proxy_mw.process_request(request3, spider) + proxy_mw.process_request(request3) assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" assert request3.meta["_auth_proxy"] == "https://a.example" @@ -527,36 +521,35 @@ class Base: proxy_mw = HttpProxyMiddleware.from_crawler(crawler) request1 = Request("http://example.com") - spider = None - proxy_mw.process_request(request1, spider) + proxy_mw.process_request(request1) assert request1.headers["Proxy-Authorization"] == b"Basic YTo=" assert request1.meta["_auth_proxy"] == "https://a.example" assert request1.meta["proxy"] == "https://a.example" response1 = self.get_response(request1, "/a") - request2 = redirect_mw.process_response(request1, response1, spider) + request2 = redirect_mw.process_response(request1, response1) assert isinstance(request2, Request) assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" assert request2.meta["_auth_proxy"] == "https://a.example" assert request2.meta["proxy"] == "https://a.example" - proxy_mw.process_request(request2, spider) + proxy_mw.process_request(request2) assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" assert request2.meta["_auth_proxy"] == "https://a.example" assert request2.meta["proxy"] == "https://a.example" response2 = self.get_response(request2, "/a") - request3 = redirect_mw.process_response(request2, response2, spider) + request3 = redirect_mw.process_response(request2, response2) assert isinstance(request3, Request) assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" assert request3.meta["_auth_proxy"] == "https://a.example" assert request3.meta["proxy"] == "https://a.example" - proxy_mw.process_request(request3, spider) + proxy_mw.process_request(request3) assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" assert request3.meta["_auth_proxy"] == "https://a.example" @@ -572,36 +565,35 @@ class Base: proxy_mw = HttpProxyMiddleware.from_crawler(crawler) request1 = Request("https://example.com") - spider = None - proxy_mw.process_request(request1, spider) + proxy_mw.process_request(request1) assert request1.headers["Proxy-Authorization"] == b"Basic YTo=" assert request1.meta["_auth_proxy"] == "https://a.example" assert request1.meta["proxy"] == "https://a.example" response1 = self.get_response(request1, "https://example.com") - request2 = redirect_mw.process_response(request1, response1, spider) + request2 = redirect_mw.process_response(request1, response1) assert isinstance(request2, Request) assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" assert request2.meta["_auth_proxy"] == "https://a.example" assert request2.meta["proxy"] == "https://a.example" - proxy_mw.process_request(request2, spider) + proxy_mw.process_request(request2) assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" assert request2.meta["_auth_proxy"] == "https://a.example" assert request2.meta["proxy"] == "https://a.example" response2 = self.get_response(request2, "https://example.com") - request3 = redirect_mw.process_response(request2, response2, spider) + request3 = redirect_mw.process_response(request2, response2) assert isinstance(request3, Request) assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" assert request3.meta["_auth_proxy"] == "https://a.example" assert request3.meta["proxy"] == "https://a.example" - proxy_mw.process_request(request3, spider) + proxy_mw.process_request(request3) assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" assert request3.meta["_auth_proxy"] == "https://a.example" @@ -617,36 +609,35 @@ class Base: proxy_mw = HttpProxyMiddleware.from_crawler(crawler) request1 = Request("https://example.com") - spider = None - proxy_mw.process_request(request1, spider) + proxy_mw.process_request(request1) assert request1.headers["Proxy-Authorization"] == b"Basic YTo=" assert request1.meta["_auth_proxy"] == "https://a.example" assert request1.meta["proxy"] == "https://a.example" response1 = self.get_response(request1, "/a") - request2 = redirect_mw.process_response(request1, response1, spider) + request2 = redirect_mw.process_response(request1, response1) assert isinstance(request2, Request) assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" assert request2.meta["_auth_proxy"] == "https://a.example" assert request2.meta["proxy"] == "https://a.example" - proxy_mw.process_request(request2, spider) + proxy_mw.process_request(request2) assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" assert request2.meta["_auth_proxy"] == "https://a.example" assert request2.meta["proxy"] == "https://a.example" response2 = self.get_response(request2, "/a") - request3 = redirect_mw.process_response(request2, response2, spider) + request3 = redirect_mw.process_response(request2, response2) assert isinstance(request3, Request) assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" assert request3.meta["_auth_proxy"] == "https://a.example" assert request3.meta["proxy"] == "https://a.example" - proxy_mw.process_request(request3, spider) + proxy_mw.process_request(request3) assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" assert request3.meta["_auth_proxy"] == "https://a.example" @@ -663,36 +654,35 @@ class Base: proxy_mw = HttpProxyMiddleware.from_crawler(crawler) request1 = Request("http://example.com") - spider = None - proxy_mw.process_request(request1, spider) + proxy_mw.process_request(request1) assert request1.headers["Proxy-Authorization"] == b"Basic YTo=" assert request1.meta["_auth_proxy"] == "https://a.example" assert request1.meta["proxy"] == "https://a.example" response1 = self.get_response(request1, "https://example.com") - request2 = redirect_mw.process_response(request1, response1, spider) + request2 = redirect_mw.process_response(request1, response1) assert isinstance(request2, Request) assert "Proxy-Authorization" not in request2.headers assert "_auth_proxy" not in request2.meta assert "proxy" not in request2.meta - proxy_mw.process_request(request2, spider) + proxy_mw.process_request(request2) assert request2.headers["Proxy-Authorization"] == b"Basic Yjo=" assert request2.meta["_auth_proxy"] == "https://b.example" assert request2.meta["proxy"] == "https://b.example" response2 = self.get_response(request2, "http://example.com") - request3 = redirect_mw.process_response(request2, response2, spider) + request3 = redirect_mw.process_response(request2, response2) assert isinstance(request3, Request) assert "Proxy-Authorization" not in request3.headers assert "_auth_proxy" not in request3.meta assert "proxy" not in request3.meta - proxy_mw.process_request(request3, spider) + proxy_mw.process_request(request3) assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" assert request3.meta["_auth_proxy"] == "https://a.example" @@ -708,36 +698,35 @@ class Base: proxy_mw = HttpProxyMiddleware.from_crawler(crawler) request1 = Request("http://example.com") - spider = None - proxy_mw.process_request(request1, spider) + proxy_mw.process_request(request1) assert request1.headers["Proxy-Authorization"] == b"Basic YTo=" assert request1.meta["_auth_proxy"] == "https://a.example" assert request1.meta["proxy"] == "https://a.example" response1 = self.get_response(request1, "https://example.com") - request2 = redirect_mw.process_response(request1, response1, spider) + request2 = redirect_mw.process_response(request1, response1) assert isinstance(request2, Request) assert "Proxy-Authorization" not in request2.headers assert "_auth_proxy" not in request2.meta assert "proxy" not in request2.meta - proxy_mw.process_request(request2, spider) + proxy_mw.process_request(request2) assert "Proxy-Authorization" not in request2.headers assert "_auth_proxy" not in request2.meta assert "proxy" not in request2.meta response2 = self.get_response(request2, "http://example.com") - request3 = redirect_mw.process_response(request2, response2, spider) + request3 = redirect_mw.process_response(request2, response2) assert isinstance(request3, Request) assert "Proxy-Authorization" not in request3.headers assert "_auth_proxy" not in request3.meta assert "proxy" not in request3.meta - proxy_mw.process_request(request3, spider) + proxy_mw.process_request(request3) assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" assert request3.meta["_auth_proxy"] == "https://a.example" @@ -753,36 +742,35 @@ class Base: proxy_mw = HttpProxyMiddleware.from_crawler(crawler) request1 = Request("http://example.com") - spider = None - proxy_mw.process_request(request1, spider) + proxy_mw.process_request(request1) assert "Proxy-Authorization" not in request1.headers assert "_auth_proxy" not in request1.meta assert "proxy" not in request1.meta response1 = self.get_response(request1, "https://example.com") - request2 = redirect_mw.process_response(request1, response1, spider) + request2 = redirect_mw.process_response(request1, response1) assert isinstance(request2, Request) assert "Proxy-Authorization" not in request2.headers assert "_auth_proxy" not in request2.meta assert "proxy" not in request2.meta - proxy_mw.process_request(request2, spider) + proxy_mw.process_request(request2) assert request2.headers["Proxy-Authorization"] == b"Basic Yjo=" assert request2.meta["_auth_proxy"] == "https://b.example" assert request2.meta["proxy"] == "https://b.example" response2 = self.get_response(request2, "http://example.com") - request3 = redirect_mw.process_response(request2, response2, spider) + request3 = redirect_mw.process_response(request2, response2) assert isinstance(request3, Request) assert "Proxy-Authorization" not in request3.headers assert "_auth_proxy" not in request3.meta assert "proxy" not in request3.meta - proxy_mw.process_request(request3, spider) + proxy_mw.process_request(request3) assert "Proxy-Authorization" not in request3.headers assert "_auth_proxy" not in request3.meta @@ -794,36 +782,35 @@ class Base: proxy_mw = HttpProxyMiddleware.from_crawler(crawler) request1 = Request("http://example.com") - spider = None - proxy_mw.process_request(request1, spider) + proxy_mw.process_request(request1) assert "Proxy-Authorization" not in request1.headers assert "_auth_proxy" not in request1.meta assert "proxy" not in request1.meta response1 = self.get_response(request1, "https://example.com") - request2 = redirect_mw.process_response(request1, response1, spider) + request2 = redirect_mw.process_response(request1, response1) assert isinstance(request2, Request) assert "Proxy-Authorization" not in request2.headers assert "_auth_proxy" not in request2.meta assert "proxy" not in request2.meta - proxy_mw.process_request(request2, spider) + proxy_mw.process_request(request2) assert "Proxy-Authorization" not in request2.headers assert "_auth_proxy" not in request2.meta assert "proxy" not in request2.meta response2 = self.get_response(request2, "http://example.com") - request3 = redirect_mw.process_response(request2, response2, spider) + request3 = redirect_mw.process_response(request2, response2) assert isinstance(request3, Request) assert "Proxy-Authorization" not in request3.headers assert "_auth_proxy" not in request3.meta assert "proxy" not in request3.meta - proxy_mw.process_request(request3, spider) + proxy_mw.process_request(request3) assert "Proxy-Authorization" not in request3.headers assert "_auth_proxy" not in request3.meta @@ -840,36 +827,35 @@ class Base: proxy_mw = HttpProxyMiddleware.from_crawler(crawler) request1 = Request("https://example.com") - spider = None - proxy_mw.process_request(request1, spider) + proxy_mw.process_request(request1) assert request1.headers["Proxy-Authorization"] == b"Basic Yjo=" assert request1.meta["_auth_proxy"] == "https://b.example" assert request1.meta["proxy"] == "https://b.example" response1 = self.get_response(request1, "http://example.com") - request2 = redirect_mw.process_response(request1, response1, spider) + request2 = redirect_mw.process_response(request1, response1) assert isinstance(request2, Request) assert "Proxy-Authorization" not in request2.headers assert "_auth_proxy" not in request2.meta assert "proxy" not in request2.meta - proxy_mw.process_request(request2, spider) + proxy_mw.process_request(request2) assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" assert request2.meta["_auth_proxy"] == "https://a.example" assert request2.meta["proxy"] == "https://a.example" response2 = self.get_response(request2, "https://example.com") - request3 = redirect_mw.process_response(request2, response2, spider) + request3 = redirect_mw.process_response(request2, response2) assert isinstance(request3, Request) assert "Proxy-Authorization" not in request3.headers assert "_auth_proxy" not in request3.meta assert "proxy" not in request3.meta - proxy_mw.process_request(request3, spider) + proxy_mw.process_request(request3) assert request3.headers["Proxy-Authorization"] == b"Basic Yjo=" assert request3.meta["_auth_proxy"] == "https://b.example" @@ -885,36 +871,35 @@ class Base: proxy_mw = HttpProxyMiddleware.from_crawler(crawler) request1 = Request("https://example.com") - spider = None - proxy_mw.process_request(request1, spider) + proxy_mw.process_request(request1) assert request1.headers["Proxy-Authorization"] == b"Basic Yjo=" assert request1.meta["_auth_proxy"] == "https://b.example" assert request1.meta["proxy"] == "https://b.example" response1 = self.get_response(request1, "http://example.com") - request2 = redirect_mw.process_response(request1, response1, spider) + request2 = redirect_mw.process_response(request1, response1) assert isinstance(request2, Request) assert "Proxy-Authorization" not in request2.headers assert "_auth_proxy" not in request2.meta assert "proxy" not in request2.meta - proxy_mw.process_request(request2, spider) + proxy_mw.process_request(request2) assert "Proxy-Authorization" not in request2.headers assert "_auth_proxy" not in request2.meta assert "proxy" not in request2.meta response2 = self.get_response(request2, "https://example.com") - request3 = redirect_mw.process_response(request2, response2, spider) + request3 = redirect_mw.process_response(request2, response2) assert isinstance(request3, Request) assert "Proxy-Authorization" not in request3.headers assert "_auth_proxy" not in request3.meta assert "proxy" not in request3.meta - proxy_mw.process_request(request3, spider) + proxy_mw.process_request(request3) assert request3.headers["Proxy-Authorization"] == b"Basic Yjo=" assert request3.meta["_auth_proxy"] == "https://b.example" @@ -930,36 +915,35 @@ class Base: proxy_mw = HttpProxyMiddleware.from_crawler(crawler) request1 = Request("https://example.com") - spider = None - proxy_mw.process_request(request1, spider) + proxy_mw.process_request(request1) assert "Proxy-Authorization" not in request1.headers assert "_auth_proxy" not in request1.meta assert "proxy" not in request1.meta response1 = self.get_response(request1, "http://example.com") - request2 = redirect_mw.process_response(request1, response1, spider) + request2 = redirect_mw.process_response(request1, response1) assert isinstance(request2, Request) assert "Proxy-Authorization" not in request2.headers assert "_auth_proxy" not in request2.meta assert "proxy" not in request2.meta - proxy_mw.process_request(request2, spider) + proxy_mw.process_request(request2) assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" assert request2.meta["_auth_proxy"] == "https://a.example" assert request2.meta["proxy"] == "https://a.example" response2 = self.get_response(request2, "https://example.com") - request3 = redirect_mw.process_response(request2, response2, spider) + request3 = redirect_mw.process_response(request2, response2) assert isinstance(request3, Request) assert "Proxy-Authorization" not in request3.headers assert "_auth_proxy" not in request3.meta assert "proxy" not in request3.meta - proxy_mw.process_request(request3, spider) + proxy_mw.process_request(request3) assert "Proxy-Authorization" not in request3.headers assert "_auth_proxy" not in request3.meta @@ -971,36 +955,35 @@ class Base: proxy_mw = HttpProxyMiddleware.from_crawler(crawler) request1 = Request("https://example.com") - spider = None - proxy_mw.process_request(request1, spider) + proxy_mw.process_request(request1) assert "Proxy-Authorization" not in request1.headers assert "_auth_proxy" not in request1.meta assert "proxy" not in request1.meta response1 = self.get_response(request1, "http://example.com") - request2 = redirect_mw.process_response(request1, response1, spider) + request2 = redirect_mw.process_response(request1, response1) assert isinstance(request2, Request) assert "Proxy-Authorization" not in request2.headers assert "_auth_proxy" not in request2.meta assert "proxy" not in request2.meta - proxy_mw.process_request(request2, spider) + proxy_mw.process_request(request2) assert "Proxy-Authorization" not in request2.headers assert "_auth_proxy" not in request2.meta assert "proxy" not in request2.meta response2 = self.get_response(request2, "https://example.com") - request3 = redirect_mw.process_response(request2, response2, spider) + request3 = redirect_mw.process_response(request2, response2) assert isinstance(request3, Request) assert "Proxy-Authorization" not in request3.headers assert "_auth_proxy" not in request3.meta assert "proxy" not in request3.meta - proxy_mw.process_request(request3, spider) + proxy_mw.process_request(request3) assert "Proxy-Authorization" not in request3.headers assert "_auth_proxy" not in request3.meta @@ -1012,9 +995,9 @@ class TestRedirectMiddleware(Base.Test): reason = 302 def setup_method(self): - self.crawler = get_crawler(Spider) - self.spider = self.crawler._create_spider("foo") - self.mw = self.mwcls.from_crawler(self.crawler) + crawler = get_crawler(DefaultSpider) + crawler.spider = crawler._create_spider() + self.mw = self.mwcls.from_crawler(crawler) def get_response(self, request, location, status=302): headers = {"Location": location} @@ -1027,14 +1010,14 @@ class TestRedirectMiddleware(Base.Test): req = Request(url, method=method) rsp = Response(url, headers={"Location": url2}, status=status) - req2 = self.mw.process_response(req, rsp, self.spider) + req2 = self.mw.process_response(req, rsp) assert isinstance(req2, Request) assert req2.url == url2 assert req2.method == method # response without Location header but with status code is 3XX should be ignored del rsp.headers["Location"] - assert self.mw.process_response(req, rsp, self.spider) is rsp + assert self.mw.process_response(req, rsp) is rsp _test("GET") _test("POST") @@ -1054,7 +1037,7 @@ class TestRedirectMiddleware(Base.Test): req = Request(url, method="HEAD") rsp = Response(url, headers={"Location": url2}, status=302) - req2 = self.mw.process_response(req, rsp, self.spider) + req2 = self.mw.process_response(req, rsp) assert isinstance(req2, Request) assert req2.url == url2 assert req2.method == "HEAD" @@ -1066,19 +1049,18 @@ class TestRedirectMiddleware(Base.Test): req = Request(url, method="HEAD") rsp = Response(url, headers={"Location": url2}, status=302) - req2 = self.mw.process_response(req, rsp, self.spider) + req2 = self.mw.process_response(req, rsp) assert isinstance(req2, Request) assert req2.url == url3 assert req2.method == "HEAD" def test_spider_handling(self): - smartspider = self.crawler._create_spider("smarty") - smartspider.handle_httpstatus_list = [404, 301, 302] + self.mw.crawler.spider.handle_httpstatus_list = [404, 301, 302] url = "http://www.example.com/301" url2 = "http://www.example.com/redirected" req = Request(url) rsp = Response(url, headers={"Location": url2}, status=301) - r = self.mw.process_response(req, rsp, smartspider) + r = self.mw.process_response(req, rsp) assert r is rsp def test_request_meta_handling(self): @@ -1087,7 +1069,7 @@ class TestRedirectMiddleware(Base.Test): def _test_passthrough(req): rsp = Response(url, headers={"Location": url2}, status=301, request=req) - r = self.mw.process_response(req, rsp, self.spider) + r = self.mw.process_response(req, rsp) assert r is rsp _test_passthrough( @@ -1103,7 +1085,7 @@ class TestRedirectMiddleware(Base.Test): headers={"Location": latin1_location}, status=302, ) - req_result = self.mw.process_response(req, resp, self.spider) + req_result = self.mw.process_response(req, resp) perc_encoded_utf8_url = "http://scrapytest.org/a%E7%E3o" assert perc_encoded_utf8_url == req_result.url @@ -1115,14 +1097,14 @@ class TestRedirectMiddleware(Base.Test): headers={"Location": utf8_location}, status=302, ) - req_result = self.mw.process_response(req, resp, self.spider) + req_result = self.mw.process_response(req, resp) perc_encoded_utf8_url = "http://scrapytest.org/a%C3%A7%C3%A3o" assert perc_encoded_utf8_url == req_result.url def test_no_location(self): request = Request("https://example.com") response = Response(request.url, status=302) - assert self.mw.process_response(request, response, self.spider) is response + assert self.mw.process_response(request, response) is response SCHEME_PARAMS = ("url", "location", "target") @@ -1166,11 +1148,10 @@ REDIRECT_SCHEME_CASES = ( @pytest.mark.parametrize(SCHEME_PARAMS, REDIRECT_SCHEME_CASES) def test_redirect_schemes(url, location, target): crawler = get_crawler(Spider) - spider = crawler._create_spider("foo") mw = RedirectMiddleware.from_crawler(crawler) request = Request(url) response = Response(url, headers={"Location": location}, status=301) - redirect = mw.process_response(request, response, spider) + redirect = mw.process_response(request, response) if target is None: assert redirect == response else: @@ -1189,7 +1170,6 @@ class TestMetaRefreshMiddleware(Base.Test): def setup_method(self): crawler = get_crawler(Spider) - self.spider = crawler._create_spider("foo") self.mw = self.mwcls.from_crawler(crawler) def _body(self, interval=5, url="http://example.org/newpage"): @@ -1201,7 +1181,7 @@ class TestMetaRefreshMiddleware(Base.Test): def test_meta_refresh(self): req = Request(url="http://example.org") rsp = HtmlResponse(req.url, body=self._body()) - req2 = self.mw.process_response(req, rsp, self.spider) + req2 = self.mw.process_response(req, rsp) assert isinstance(req2, Request) assert req2.url == "http://example.org/newpage" @@ -1211,7 +1191,7 @@ class TestMetaRefreshMiddleware(Base.Test): rsp = HtmlResponse( url="http://example.org", body=self._body(interval=1000), encoding="utf-8" ) - rsp2 = self.mw.process_response(req, rsp, self.spider) + rsp2 = self.mw.process_response(req, rsp) assert rsp is rsp2 def test_meta_refresh_trough_posted_request(self): @@ -1222,7 +1202,7 @@ class TestMetaRefreshMiddleware(Base.Test): headers={"Content-Type": "text/plain", "Content-length": "4"}, ) rsp = HtmlResponse(req.url, body=self._body()) - req2 = self.mw.process_response(req, rsp, self.spider) + req2 = self.mw.process_response(req, rsp) assert isinstance(req2, Request) assert req2.url == "http://example.org/newpage" @@ -1242,7 +1222,7 @@ class TestMetaRefreshMiddleware(Base.Test): """content="0;URL='http://example.org/newpage'">""" ) rsp = HtmlResponse(req.url, body=body.encode()) - response = self.mw.process_response(req, rsp, self.spider) + response = self.mw.process_response(req, rsp) assert isinstance(response, Response) def test_ignore_tags_1_x_list(self): @@ -1256,7 +1236,7 @@ class TestMetaRefreshMiddleware(Base.Test): """content="0;URL='http://example.org/newpage'">""" ) rsp = HtmlResponse(req.url, body=body.encode()) - response = mw.process_response(req, rsp, self.spider) + response = mw.process_response(req, rsp) assert isinstance(response, Response) @@ -1288,11 +1268,10 @@ class TestMetaRefreshMiddleware(Base.Test): ) def test_meta_refresh_schemes(url, location, target): crawler = get_crawler(Spider) - spider = crawler._create_spider("foo") mw = MetaRefreshMiddleware.from_crawler(crawler) request = Request(url) response = HtmlResponse(url, body=meta_refresh_body(location)) - redirect = mw.process_response(request, response, spider) + redirect = mw.process_response(request, response) if target is None: assert redirect == response else: diff --git a/tests/test_downloadermiddleware_retry.py b/tests/test_downloadermiddleware_retry.py index ffdcdf49e..affcc79c1 100644 --- a/tests/test_downloadermiddleware_retry.py +++ b/tests/test_downloadermiddleware_retry.py @@ -10,6 +10,8 @@ from twisted.internet.error import ( DNSLookupError, TCPTimedOutError, ) +from twisted.internet.error import ConnectionRefusedError as TxConnectionRefusedError +from twisted.internet.error import TimeoutError as TxTimeoutError from twisted.web.client import ResponseFailed from scrapy.downloadermiddlewares.retry import RetryMiddleware, get_retry_request @@ -17,20 +19,21 @@ from scrapy.exceptions import IgnoreRequest from scrapy.http import Request, Response from scrapy.settings.default_settings import RETRY_EXCEPTIONS from scrapy.spiders import Spider +from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler class TestRetry: def setup_method(self): - self.crawler = get_crawler(Spider) - self.spider = self.crawler._create_spider("foo") + self.crawler = get_crawler(DefaultSpider) + self.crawler.spider = self.crawler._create_spider() self.mw = RetryMiddleware.from_crawler(self.crawler) self.mw.max_retry_times = 2 def test_priority_adjust(self): req = Request("http://www.scrapytest.org/503") rsp = Response("http://www.scrapytest.org/503", body=b"", status=503) - req2 = self.mw.process_response(req, rsp, self.spider) + req2 = self.mw.process_response(req, rsp) assert req2.priority < req.priority def test_404(self): @@ -38,14 +41,14 @@ class TestRetry: rsp = Response("http://www.scrapytest.org/404", body=b"", status=404) # dont retry 404s - assert self.mw.process_response(req, rsp, self.spider) is rsp + assert self.mw.process_response(req, rsp) is rsp def test_dont_retry(self): req = Request("http://www.scrapytest.org/503", meta={"dont_retry": True}) rsp = Response("http://www.scrapytest.org/503", body=b"", status=503) # first retry - r = self.mw.process_response(req, rsp, self.spider) + r = self.mw.process_response(req, rsp) assert r is rsp # Test retry when dont_retry set to False @@ -53,13 +56,13 @@ class TestRetry: rsp = Response("http://www.scrapytest.org/503") # first retry - r = self.mw.process_response(req, rsp, self.spider) + r = self.mw.process_response(req, rsp) assert r is rsp def test_dont_retry_exc(self): req = Request("http://www.scrapytest.org/503", meta={"dont_retry": True}) - r = self.mw.process_exception(req, DNSLookupError(), self.spider) + r = self.mw.process_exception(req, DNSLookupError()) assert r is None def test_503(self): @@ -67,17 +70,17 @@ class TestRetry: rsp = Response("http://www.scrapytest.org/503", body=b"", status=503) # first retry - req = self.mw.process_response(req, rsp, self.spider) + req = self.mw.process_response(req, rsp) assert isinstance(req, Request) assert req.meta["retry_times"] == 1 # second retry - req = self.mw.process_response(req, rsp, self.spider) + req = self.mw.process_response(req, rsp) assert isinstance(req, Request) assert req.meta["retry_times"] == 2 # discard it - assert self.mw.process_response(req, rsp, self.spider) is rsp + assert self.mw.process_response(req, rsp) is rsp assert self.crawler.stats.get_value("retry/max_reached") == 1 assert ( @@ -91,12 +94,12 @@ class TestRetry: ConnectError, ConnectionDone, ConnectionLost, - ConnectionRefusedError, + TxConnectionRefusedError, defer.TimeoutError, DNSLookupError, ResponseFailed, TCPTimedOutError, - TimeoutError, + TxTimeoutError, ] for exc in exceptions: @@ -116,7 +119,8 @@ class TestRetry: settings_dict = { "RETRY_EXCEPTIONS": [*RETRY_EXCEPTIONS, exc], } - crawler = get_crawler(Spider, settings_dict=settings_dict) + crawler = get_crawler(DefaultSpider, settings_dict=settings_dict) + crawler.spider = crawler._create_spider() mw = RetryMiddleware.from_crawler(crawler) req = Request(f"http://www.scrapytest.org/{exc.__name__}") self._test_retry_exception(req, exc("foo"), mw) @@ -126,65 +130,61 @@ class TestRetry: mw = self.mw # first retry - req = mw.process_exception(req, exception, self.spider) + req = mw.process_exception(req, exception) assert isinstance(req, Request) assert req.meta["retry_times"] == 1 # second retry - req = mw.process_exception(req, exception, self.spider) + req = mw.process_exception(req, exception) assert isinstance(req, Request) assert req.meta["retry_times"] == 2 # discard it - req = mw.process_exception(req, exception, self.spider) + req = mw.process_exception(req, exception) assert req is None class TestMaxRetryTimes: invalid_url = "http://www.scrapytest.org/invalid_url" - def get_spider_and_middleware(self, settings=None): - crawler = get_crawler(Spider, settings or {}) - spider = crawler._create_spider("foo") - middleware = RetryMiddleware.from_crawler(crawler) - return spider, middleware + def get_middleware(self, settings=None): + crawler = get_crawler(DefaultSpider, settings or {}) + crawler.spider = crawler._create_spider() + return RetryMiddleware.from_crawler(crawler) def test_with_settings_zero(self): max_retry_times = 0 settings = {"RETRY_TIMES": max_retry_times} - spider, middleware = self.get_spider_and_middleware(settings) + middleware = self.get_middleware(settings) req = Request(self.invalid_url) self._test_retry( req, DNSLookupError("foo"), max_retry_times, - spider=spider, middleware=middleware, ) def test_with_metakey_zero(self): max_retry_times = 0 - spider, middleware = self.get_spider_and_middleware() + middleware = self.get_middleware() meta = {"max_retry_times": max_retry_times} req = Request(self.invalid_url, meta=meta) self._test_retry( req, DNSLookupError("foo"), max_retry_times, - spider=spider, middleware=middleware, ) def test_without_metakey(self): max_retry_times = 5 settings = {"RETRY_TIMES": max_retry_times} - spider, middleware = self.get_spider_and_middleware(settings) + middleware = self.get_middleware(settings) req = Request(self.invalid_url) self._test_retry( req, DNSLookupError("foo"), max_retry_times, - spider=spider, middleware=middleware, ) @@ -196,20 +196,18 @@ class TestMaxRetryTimes: req2 = Request(self.invalid_url) settings = {"RETRY_TIMES": middleware_max_retry_times} - spider, middleware = self.get_spider_and_middleware(settings) + middleware = self.get_middleware(settings) self._test_retry( req1, DNSLookupError("foo"), meta_max_retry_times, - spider=spider, middleware=middleware, ) self._test_retry( req2, DNSLookupError("foo"), middleware_max_retry_times, - spider=spider, middleware=middleware, ) @@ -221,26 +219,24 @@ class TestMaxRetryTimes: req2 = Request(self.invalid_url) settings = {"RETRY_TIMES": middleware_max_retry_times} - spider, middleware = self.get_spider_and_middleware(settings) + middleware = self.get_middleware(settings) self._test_retry( req1, DNSLookupError("foo"), meta_max_retry_times, - spider=spider, middleware=middleware, ) self._test_retry( req2, DNSLookupError("foo"), middleware_max_retry_times, - spider=spider, middleware=middleware, ) def test_with_dont_retry(self): max_retry_times = 4 - spider, middleware = self.get_spider_and_middleware() + middleware = self.get_middleware() meta = { "max_retry_times": max_retry_times, "dont_retry": True, @@ -250,7 +246,6 @@ class TestMaxRetryTimes: req, DNSLookupError("foo"), 0, - spider=spider, middleware=middleware, ) @@ -259,18 +254,16 @@ class TestMaxRetryTimes: req, exception, max_retry_times, - spider=None, middleware=None, ): - spider = spider or self.spider middleware = middleware or self.mw for i in range(max_retry_times): - req = middleware.process_exception(req, exception, spider) + req = middleware.process_exception(req, exception) assert isinstance(req, Request) # discard it - req = middleware.process_exception(req, exception, spider) + req = middleware.process_exception(req, exception) assert req is None diff --git a/tests/test_downloadermiddleware_robotstxt.py b/tests/test_downloadermiddleware_robotstxt.py index dd5d47cab..bedf40279 100644 --- a/tests/test_downloadermiddleware_robotstxt.py +++ b/tests/test_downloadermiddleware_robotstxt.py @@ -1,34 +1,38 @@ from __future__ import annotations +import asyncio from typing import TYPE_CHECKING from unittest import mock import pytest from twisted.internet import error -from twisted.internet.defer import Deferred, maybeDeferred +from twisted.internet.defer import Deferred, DeferredList from twisted.python import failure -from twisted.trial import unittest from scrapy.downloadermiddlewares.robotstxt import RobotsTxtMiddleware -from scrapy.downloadermiddlewares.robotstxt import logger as mw_module_logger from scrapy.exceptions import IgnoreRequest, NotConfigured from scrapy.http import Request, Response, TextResponse from scrapy.http.request import NO_CALLBACK from scrapy.settings import Settings -from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future +from scrapy.utils.asyncio import call_later +from scrapy.utils.defer import ( + deferred_f_from_coro_f, + deferred_from_coro, + maybe_deferred_to_future, +) from tests.test_robotstxt_interface import rerp_available if TYPE_CHECKING: from scrapy.crawler import Crawler -class TestRobotsTxtMiddleware(unittest.TestCase): - def setUp(self): +class TestRobotsTxtMiddleware: + def setup_method(self): self.crawler = mock.MagicMock() self.crawler.settings = Settings() - self.crawler.engine.download = mock.MagicMock() + self.crawler.engine.download_async = mock.AsyncMock() - def tearDown(self): + def teardown_method(self): del self.crawler def test_robotstxt_settings(self): @@ -52,14 +56,12 @@ Disallow: /some/randome/page.html """.encode() response = TextResponse("http://site.local/robots.txt", body=ROBOTS) - def return_response(request): - from twisted.internet import reactor - + async def return_response(request): deferred = Deferred() - reactor.callFromThread(deferred.callback, response) - return deferred + call_later(0, deferred.callback, response) + return await maybe_deferred_to_future(deferred) - crawler.engine.download.side_effect = return_response + crawler.engine.download_async.side_effect = return_response return crawler @deferred_f_from_coro_f @@ -76,6 +78,25 @@ Disallow: /some/randome/page.html Request("http://site.local/wiki/Käyttäjä:"), middleware ) + @deferred_f_from_coro_f + async def test_robotstxt_multiple_reqs(self) -> None: + middleware = RobotsTxtMiddleware(self._get_successful_crawler()) + d1 = deferred_from_coro( + middleware.process_request(Request("http://site.local/allowed1")) + ) + d2 = deferred_from_coro( + middleware.process_request(Request("http://site.local/allowed2")) + ) + await maybe_deferred_to_future(DeferredList([d1, d2], fireOnOneErrback=True)) + + @pytest.mark.only_asyncio + @deferred_f_from_coro_f + async def test_robotstxt_multiple_reqs_asyncio(self) -> None: + middleware = RobotsTxtMiddleware(self._get_successful_crawler()) + c1 = middleware.process_request(Request("http://site.local/allowed1")) + c2 = middleware.process_request(Request("http://site.local/allowed2")) + await asyncio.gather(c1, c2) + @deferred_f_from_coro_f async def test_robotstxt_ready_parser(self): middleware = RobotsTxtMiddleware(self._get_successful_crawler()) @@ -103,14 +124,12 @@ Disallow: /some/randome/page.html "http://site.local/robots.txt", body=b"GIF89a\xd3\x00\xfe\x00\xa2" ) - def return_response(request): - from twisted.internet import reactor - + async def return_response(request): deferred = Deferred() - reactor.callFromThread(deferred.callback, response) - return deferred + call_later(0, deferred.callback, response) + return await maybe_deferred_to_future(deferred) - crawler.engine.download.side_effect = return_response + crawler.engine.download_async.side_effect = return_response return crawler @deferred_f_from_coro_f @@ -127,14 +146,12 @@ Disallow: /some/randome/page.html crawler.settings.set("ROBOTSTXT_OBEY", True) response = Response("http://site.local/robots.txt") - def return_response(request): - from twisted.internet import reactor - + async def return_response(request): deferred = Deferred() - reactor.callFromThread(deferred.callback, response) - return deferred + call_later(0, deferred.callback, response) + return await maybe_deferred_to_future(deferred) - crawler.engine.download.side_effect = return_response + crawler.engine.download_async.side_effect = return_response return crawler @deferred_f_from_coro_f @@ -146,37 +163,30 @@ Disallow: /some/randome/page.html await self.assertNotIgnored(Request("http://site.local/static/"), middleware) @deferred_f_from_coro_f - async def test_robotstxt_error(self): + async def test_robotstxt_error(self, caplog: pytest.LogCaptureFixture) -> None: self.crawler.settings.set("ROBOTSTXT_OBEY", True) err = error.DNSLookupError("Robotstxt address not found") - def return_failure(request): - from twisted.internet import reactor - + async def return_failure(request): deferred = Deferred() - reactor.callFromThread(deferred.errback, failure.Failure(err)) - return deferred + call_later(0, deferred.errback, failure.Failure(err)) + return await maybe_deferred_to_future(deferred) - self.crawler.engine.download.side_effect = return_failure + self.crawler.engine.download_async.side_effect = return_failure middleware = RobotsTxtMiddleware(self.crawler) - middleware._logerror = mock.MagicMock(side_effect=middleware._logerror) - await maybe_deferred_to_future( - middleware.process_request(Request("http://site.local"), None) - ) - assert middleware._logerror.called + await middleware.process_request(Request("http://site.local")) + assert "DNS lookup failed: Robotstxt address not found" in caplog.text @deferred_f_from_coro_f async def test_robotstxt_immediate_error(self): self.crawler.settings.set("ROBOTSTXT_OBEY", True) err = error.DNSLookupError("Robotstxt address not found") - def immediate_failure(request): - deferred = Deferred() - deferred.errback(failure.Failure(err)) - return deferred + async def immediate_failure(request): + raise err - self.crawler.engine.download.side_effect = immediate_failure + self.crawler.engine.download_async.side_effect = immediate_failure middleware = RobotsTxtMiddleware(self.crawler) await self.assertNotIgnored(Request("http://site.local"), middleware) @@ -185,20 +195,21 @@ Disallow: /some/randome/page.html async def test_ignore_robotstxt_request(self): self.crawler.settings.set("ROBOTSTXT_OBEY", True) - def ignore_request(request): - from twisted.internet import reactor - + async def ignore_request(request): deferred = Deferred() - reactor.callFromThread(deferred.errback, failure.Failure(IgnoreRequest())) - return deferred + call_later(0, deferred.errback, failure.Failure(IgnoreRequest())) + return await maybe_deferred_to_future(deferred) - self.crawler.engine.download.side_effect = ignore_request + self.crawler.engine.download_async.side_effect = ignore_request middleware = RobotsTxtMiddleware(self.crawler) - mw_module_logger.error = mock.MagicMock() - - await self.assertNotIgnored(Request("http://site.local/allowed"), middleware) - assert not mw_module_logger.error.called # type: ignore[attr-defined] + with mock.patch( + "scrapy.downloadermiddlewares.robotstxt.logger" + ) as mw_module_logger: + await self.assertNotIgnored( + Request("http://site.local/allowed"), middleware + ) + assert not mw_module_logger.error.called def test_robotstxt_user_agent_setting(self): crawler = self._get_successful_crawler() @@ -206,42 +217,41 @@ Disallow: /some/randome/page.html crawler.settings.set("USER_AGENT", "Mozilla/5.0 (X11; Linux x86_64)") middleware = RobotsTxtMiddleware(crawler) rp = mock.MagicMock(return_value=True) - middleware.process_request_2(rp, Request("http://site.local/allowed"), None) + middleware.process_request_2(rp, Request("http://site.local/allowed")) rp.allowed.assert_called_once_with("http://site.local/allowed", "Examplebot") - def test_robotstxt_local_file(self): + @deferred_f_from_coro_f + async def test_robotstxt_local_file(self): middleware = RobotsTxtMiddleware(self._get_emptybody_crawler()) - assert not middleware.process_request( - Request("data:text/plain,Hello World data"), None - ) - assert not middleware.process_request( - Request("file:///tests/sample_data/test_site/nothinghere.html"), None - ) - assert isinstance( - middleware.process_request(Request("http://site.local/allowed"), None), - Deferred, + middleware.process_request_2 = mock.MagicMock() + + await middleware.process_request(Request("data:text/plain,Hello World data")) + assert not middleware.process_request_2.called + + await middleware.process_request( + Request("file:///tests/sample_data/test_site/nothinghere.html") ) + assert not middleware.process_request_2.called + + await middleware.process_request(Request("http://site.local/allowed")) + assert middleware.process_request_2.called async def assertNotIgnored( self, request: Request, middleware: RobotsTxtMiddleware ) -> None: - spider = None # not actually used - result = await maybe_deferred_to_future( - maybeDeferred(middleware.process_request, request, spider) # type: ignore[call-overload] - ) - assert result is None + try: + await middleware.process_request(request) + except IgnoreRequest: + pytest.fail("IgnoreRequest was raised unexpectedly") async def assertIgnored( self, request: Request, middleware: RobotsTxtMiddleware ) -> None: - spider = None # not actually used with pytest.raises(IgnoreRequest): - await maybe_deferred_to_future( - maybeDeferred(middleware.process_request, request, spider) # type: ignore[call-overload] - ) + await middleware.process_request(request) def assertRobotsTxtRequested(self, base_url: str) -> None: - calls = self.crawler.engine.download.call_args_list + calls = self.crawler.engine.download_async.call_args_list request = calls[0][0][0] assert request.url == f"{base_url}/robots.txt" assert request.callback == NO_CALLBACK @@ -249,8 +259,8 @@ Disallow: /some/randome/page.html @pytest.mark.skipif(not rerp_available(), reason="Rerp parser is not installed") class TestRobotsTxtMiddlewareWithRerp(TestRobotsTxtMiddleware): - def setUp(self): - super().setUp() + def setup_method(self): + super().setup_method() self.crawler.settings.set( "ROBOTSTXT_PARSER", "scrapy.robotstxt.RerpRobotParser" ) diff --git a/tests/test_downloadermiddleware_stats.py b/tests/test_downloadermiddleware_stats.py index 748ef7d76..67af4264c 100644 --- a/tests/test_downloadermiddleware_stats.py +++ b/tests/test_downloadermiddleware_stats.py @@ -11,29 +11,28 @@ class MyException(Exception): class TestDownloaderStats: def setup_method(self): self.crawler = get_crawler(Spider) - self.spider = self.crawler._create_spider("scrapytest.org") self.mw = DownloaderStats(self.crawler.stats) - self.crawler.stats.open_spider(self.spider) + self.crawler.stats.open_spider() self.req = Request("http://scrapytest.org") self.res = Response("scrapytest.org", status=400) def assertStatsEqual(self, key, value): - assert self.crawler.stats.get_value(key, spider=self.spider) == value, str( - self.crawler.stats.get_stats(self.spider) + assert self.crawler.stats.get_value(key) == value, str( + self.crawler.stats.get_stats() ) def test_process_request(self): - self.mw.process_request(self.req, self.spider) + self.mw.process_request(self.req) self.assertStatsEqual("downloader/request_count", 1) def test_process_response(self): - self.mw.process_response(self.req, self.res, self.spider) + self.mw.process_response(self.req, self.res) self.assertStatsEqual("downloader/response_count", 1) def test_process_exception(self): - self.mw.process_exception(self.req, MyException(), self.spider) + self.mw.process_exception(self.req, MyException()) self.assertStatsEqual("downloader/exception_count", 1) self.assertStatsEqual( "downloader/exception_type_count/tests.test_downloadermiddleware_stats.MyException", @@ -41,4 +40,4 @@ class TestDownloaderStats: ) def teardown_method(self): - self.crawler.stats.close_spider(self.spider, "") + self.crawler.stats.close_spider() diff --git a/tests/test_downloadermiddleware_useragent.py b/tests/test_downloadermiddleware_useragent.py index 1497f8c67..60dc2ae7a 100644 --- a/tests/test_downloadermiddleware_useragent.py +++ b/tests/test_downloadermiddleware_useragent.py @@ -11,9 +11,9 @@ class TestUserAgentMiddleware: return spider, UserAgentMiddleware.from_crawler(crawler) def test_default_agent(self): - spider, mw = self.get_spider_and_mw("default_useragent") + _, mw = self.get_spider_and_mw("default_useragent") req = Request("http://scrapytest.org/") - assert mw.process_request(req, spider) is None + assert mw.process_request(req) is None assert req.headers["User-Agent"] == b"default_useragent" def test_remove_agent(self): @@ -22,7 +22,7 @@ class TestUserAgentMiddleware: spider.user_agent = None mw.spider_opened(spider) req = Request("http://scrapytest.org/") - assert mw.process_request(req, spider) is None + assert mw.process_request(req) is None assert req.headers.get("User-Agent") is None def test_spider_agent(self): @@ -30,7 +30,7 @@ class TestUserAgentMiddleware: spider.user_agent = "spider_useragent" mw.spider_opened(spider) req = Request("http://scrapytest.org/") - assert mw.process_request(req, spider) is None + assert mw.process_request(req) is None assert req.headers["User-Agent"] == b"spider_useragent" def test_header_agent(self): @@ -40,7 +40,7 @@ class TestUserAgentMiddleware: req = Request( "http://scrapytest.org/", headers={"User-Agent": "header_useragent"} ) - assert mw.process_request(req, spider) is None + assert mw.process_request(req) is None assert req.headers["User-Agent"] == b"header_useragent" def test_no_agent(self): @@ -48,5 +48,5 @@ class TestUserAgentMiddleware: spider.user_agent = None mw.spider_opened(spider) req = Request("http://scrapytest.org/") - assert mw.process_request(req, spider) is None + assert mw.process_request(req) is None assert "User-Agent" not in req.headers diff --git a/tests/test_downloaderslotssettings.py b/tests/test_downloaderslotssettings.py index ddac95edf..960fcb30a 100644 --- a/tests/test_downloaderslotssettings.py +++ b/tests/test_downloaderslotssettings.py @@ -1,13 +1,13 @@ import time from twisted.internet.defer import inlineCallbacks -from twisted.trial.unittest import TestCase from scrapy import Request from scrapy.core.downloader import Downloader, Slot from scrapy.crawler import CrawlerRunner +from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler -from tests.mockserver import MockServer +from tests.mockserver.http import MockServer from tests.spiders import MetaSpider @@ -49,17 +49,17 @@ class DownloaderSlotsSettingsTestSpider(MetaSpider): self.times[slot].append(time.time()) -class TestCrawl(TestCase): +class TestCrawl: @classmethod - def setUpClass(cls): + def setup_class(cls): cls.mockserver = MockServer() cls.mockserver.__enter__() @classmethod - def tearDownClass(cls): + def teardown_class(cls): cls.mockserver.__exit__(None, None, None) - def setUp(self): + def setup_method(self): self.runner = CrawlerRunner() @inlineCallbacks @@ -90,11 +90,12 @@ def test_params(): "example.com": params, }, } - crawler = get_crawler(settings_dict=settings) + crawler = get_crawler(DefaultSpider, settings_dict=settings) + crawler.spider = crawler._create_spider() downloader = Downloader(crawler) downloader._slot_gc_loop.stop() # Prevent an unclean reactor. request = Request("https://example.com") - _, actual = downloader._get_slot(request, spider=None) + _, actual = downloader._get_slot(request) expected = Slot(**params) for param in params: assert getattr(expected, param) == getattr(actual, param), ( diff --git a/tests/test_dupefilters.py b/tests/test_dupefilters.py index d5e1b37f7..b38bf9570 100644 --- a/tests/test_dupefilters.py +++ b/tests/test_dupefilters.py @@ -16,12 +16,12 @@ from scrapy.utils.test import get_crawler from tests.spiders import SimpleSpider -def _get_dupefilter(*, crawler=None, settings=None, open=True): +def _get_dupefilter(*, crawler=None, settings=None, open_=True): if crawler is None: crawler = get_crawler(settings_dict=settings) scheduler = Scheduler.from_crawler(crawler) dupefilter = scheduler.df - if open: + if open_: dupefilter.open() return dupefilter @@ -77,7 +77,7 @@ class TestRFPDupeFilter: path = tempfile.mkdtemp() try: - df = _get_dupefilter(settings={"JOBDIR": path}, open=False) + df = _get_dupefilter(settings={"JOBDIR": path}, open_=False) try: df.open() assert not df.request_seen(r1) @@ -85,7 +85,7 @@ class TestRFPDupeFilter: finally: df.close("finished") - df2 = _get_dupefilter(settings={"JOBDIR": path}, open=False) + df2 = _get_dupefilter(settings={"JOBDIR": path}, open_=False) assert df != df2 try: df2.open() diff --git a/tests/test_engine.py b/tests/test_engine.py index e181a36cf..430d599c8 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -1,47 +1,48 @@ -""" -Scrapy engine tests - -This starts a testing web server (using twisted.server.Site) and then crawls it -with the Scrapy crawler. - -To view the testing web server in a browser you can start it by running this -module with the ``runserver`` argument:: - - python test_engine.py runserver -""" +from __future__ import annotations +import asyncio import re import subprocess import sys from collections import defaultdict from dataclasses import dataclass from logging import DEBUG -from pathlib import Path -from threading import Timer -from unittest.mock import Mock +from typing import TYPE_CHECKING, cast +from unittest.mock import Mock, call from urllib.parse import urlparse import attr import pytest from itemadapter import ItemAdapter from pydispatch import dispatcher +from testfixtures import LogCapture from twisted.internet import defer from twisted.internet.defer import inlineCallbacks -from twisted.trial import unittest -from twisted.web import server, static, util from scrapy import signals from scrapy.core.engine import ExecutionEngine, _Slot from scrapy.core.scheduler import BaseScheduler from scrapy.exceptions import CloseSpider, IgnoreRequest -from scrapy.http import Request +from scrapy.http import Request, Response from scrapy.item import Field, Item from scrapy.linkextractors import LinkExtractor -from scrapy.signals import request_scheduled from scrapy.spiders import Spider +from scrapy.utils.defer import ( + _schedule_coro, + deferred_f_from_coro_f, + deferred_from_coro, + maybe_deferred_to_future, +) from scrapy.utils.signal import disconnect_all +from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler -from tests import get_testdata, tests_datadir +from tests import get_testdata + +if TYPE_CHECKING: + from scrapy.core.scheduler import Scheduler + from scrapy.crawler import Crawler + from scrapy.statscollectors import MemoryStatsCollector + from tests.mockserver.http import MockServer class MyItem(Item): @@ -66,7 +67,6 @@ class DataClassItem: class MySpider(Spider): name = "scrapytest.org" - allowed_domains = ["scrapytest.org", "localhost"] itemurl_re = re.compile(r"item\d+.html") name_re = re.compile(r"

(.*?)

", re.MULTILINE) @@ -131,25 +131,6 @@ class ChangeCloseReasonSpider(MySpider): raise CloseSpider(reason="custom_reason") -def start_test_site(debug=False): - from twisted.internet import reactor - - root_dir = Path(tests_datadir, "test_site") - r = static.File(str(root_dir)) - r.putChild(b"redirect", util.Redirect(b"/redirected")) - r.putChild(b"redirected", static.Data(b"Redirected here", "text/plain")) - numbers = [str(x).encode("utf8") for x in range(2**18)] - r.putChild(b"numbers", static.Data(b"".join(numbers), "text/plain")) - - port = reactor.listenTCP(0, server.Site(r), interface="127.0.0.1") - if debug: - print( - f"Test server running at http://localhost:{port.getHost().port}/ " - "- hit Ctrl-C to finish." - ) - return port - - class CrawlerRun: """A class to run the crawler and keep track of events occurred""" @@ -165,12 +146,11 @@ class CrawlerRun: self.signals_caught = {} self.spider_class = spider_class - def run(self): - self.port = start_test_site() - self.portno = self.port.getHost().port + async def run(self, mockserver: MockServer) -> None: + self.mockserver = mockserver start_urls = [ - self.geturl("/"), + self.geturl("/static/"), self.geturl("/redirect"), self.geturl("/redirect"), # duplicate self.geturl("/numbers"), @@ -195,20 +175,19 @@ class CrawlerRun: ) self.crawler.crawl(start_urls=start_urls) - self.deferred = defer.Deferred() + self.deferred: defer.Deferred[None] = defer.Deferred() dispatcher.connect(self.stop, signals.engine_stopped) - return self.deferred + await maybe_deferred_to_future(self.deferred) - def stop(self): - self.port.stopListening() # FIXME: wait for this Deferred + async def stop(self): for name, signal in vars(signals).items(): if not name.startswith("_"): disconnect_all(signal) self.deferred.callback(None) - return self.crawler.stop() + await self.crawler.stop_async() - def geturl(self, path): - return f"http://localhost:{self.portno}{path}" + def geturl(self, path: str) -> str: + return self.mockserver.url(path) def getpath(self, url): u = urlparse(url) @@ -246,16 +225,16 @@ class CrawlerRun: self.signals_caught[sig] = signalargs -class TestEngineBase(unittest.TestCase): +class TestEngineBase: @staticmethod def _assert_visited_urls(run: CrawlerRun) -> None: must_be_visited = [ - "/", + "/static/", "/redirect", "/redirected", - "/item1.html", - "/item2.html", - "/item999.html", + "/static/item1.html", + "/static/item2.html", + "/static/item999.html", ] urls_visited = {rp[0].url for rp in run.respplug} urls_expected = {run.geturl(p) for p in must_be_visited} @@ -267,7 +246,11 @@ class TestEngineBase(unittest.TestCase): def _assert_scheduled_requests(run: CrawlerRun, count: int) -> None: assert len(run.reqplug) == count - paths_expected = ["/item999.html", "/item2.html", "/item1.html"] + paths_expected = [ + "/static/item999.html", + "/static/item2.html", + "/static/item1.html", + ] urls_requested = {rq[0].url for rq in run.reqplug} urls_expected = {run.geturl(p) for p in paths_expected} @@ -289,7 +272,7 @@ class TestEngineBase(unittest.TestCase): assert len(run.reqreached) == count for response, _ in run.respplug: - if run.getpath(response.url) == "/item999.html": + if run.getpath(response.url) == "/static/item999.html": assert response.status == 404 if run.getpath(response.url) == "/redirect": assert response.status == 302 @@ -335,11 +318,11 @@ class TestEngineBase(unittest.TestCase): assert len(run.bytes) == 9 for request, data in run.bytes.items(): joined_data = b"".join(data) - if run.getpath(request.url) == "/": + if run.getpath(request.url) == "/static/": assert joined_data == get_testdata("test_site", "index.html") - elif run.getpath(request.url) == "/item1.html": + elif run.getpath(request.url) == "/static/item1.html": assert joined_data == get_testdata("test_site", "item1.html") - elif run.getpath(request.url) == "/item2.html": + elif run.getpath(request.url) == "/static/item2.html": assert joined_data == get_testdata("test_site", "item2.html") elif run.getpath(request.url) == "/redirected": assert joined_data == b"Redirected here" @@ -354,7 +337,7 @@ class TestEngineBase(unittest.TestCase): b" \n" b"\n" ) - elif run.getpath(request.url) == "/tem999.html": + elif run.getpath(request.url) == "/static/item999.html": assert ( joined_data == b"\n\n" b" 404 - No Such Resource\n" @@ -391,8 +374,8 @@ class TestEngineBase(unittest.TestCase): class TestEngine(TestEngineBase): - @inlineCallbacks - def test_crawler(self): + @deferred_f_from_coro_f + async def test_crawler(self, mockserver: MockServer) -> None: for spider in ( MySpider, DictItemsSpider, @@ -400,7 +383,7 @@ class TestEngine(TestEngineBase): DataClassItemsSpider, ): run = CrawlerRun(spider) - yield run.run() + await run.run(mockserver) self._assert_visited_urls(run) self._assert_scheduled_requests(run, count=9) self._assert_downloaded_responses(run, count=9) @@ -408,32 +391,34 @@ class TestEngine(TestEngineBase): self._assert_signals_caught(run) self._assert_bytes_received(run) - @inlineCallbacks - def test_crawler_dupefilter(self): + @deferred_f_from_coro_f + async def test_crawler_dupefilter(self, mockserver: MockServer) -> None: run = CrawlerRun(DupeFilterSpider) - yield run.run() + await run.run(mockserver) self._assert_scheduled_requests(run, count=8) self._assert_dropped_requests(run) - @inlineCallbacks - def test_crawler_itemerror(self): + @deferred_f_from_coro_f + async def test_crawler_itemerror(self, mockserver: MockServer) -> None: run = CrawlerRun(ItemZeroDivisionErrorSpider) - yield run.run() + await run.run(mockserver) self._assert_items_error(run) - @inlineCallbacks - def test_crawler_change_close_reason_on_idle(self): + @deferred_f_from_coro_f + async def test_crawler_change_close_reason_on_idle( + self, mockserver: MockServer + ) -> None: run = CrawlerRun(ChangeCloseReasonSpider) - yield run.run() + await run.run(mockserver) assert { "spider": run.crawler.spider, "reason": "custom_reason", } == run.signals_caught[signals.spider_closed] - @inlineCallbacks - def test_close_downloader(self): + @deferred_f_from_coro_f + async def test_close_downloader(self): e = ExecutionEngine(get_crawler(MySpider), lambda _: None) - yield e.close() + await e.close_async() def test_close_without_downloader(self): class CustomException(Exception): @@ -450,12 +435,45 @@ class TestEngine(TestEngineBase): @inlineCallbacks def test_start_already_running_exception(self): - e = ExecutionEngine(get_crawler(MySpider), lambda _: None) - yield e.open_spider(MySpider(), []) - e.start() + crawler = get_crawler(DefaultSpider) + crawler.spider = crawler._create_spider() + e = ExecutionEngine(crawler, lambda _: None) + yield deferred_from_coro(e.open_spider_async()) + _schedule_coro(e.start_async()) with pytest.raises(RuntimeError, match="Engine already running"): - yield e.start() - yield e.stop() + yield deferred_from_coro(e.start_async()) + yield deferred_from_coro(e.stop_async()) + + @pytest.mark.only_asyncio + @deferred_f_from_coro_f + async def test_start_already_running_exception_asyncio(self): + crawler = get_crawler(DefaultSpider) + crawler.spider = crawler._create_spider() + e = ExecutionEngine(crawler, lambda _: None) + await e.open_spider_async() + with pytest.raises(RuntimeError, match="Engine already running"): + await asyncio.gather(e.start_async(), e.start_async()) + await e.stop_async() + + @inlineCallbacks + def test_start_request_processing_exception(self): + class BadRequestFingerprinter: + def fingerprint(self, request): + raise ValueError # to make Scheduler.enqueue_request() fail + + class SimpleSpider(Spider): + name = "simple" + + async def start(self): + yield Request("data:,") + + crawler = get_crawler( + SimpleSpider, {"REQUEST_FINGERPRINTER_CLASS": BadRequestFingerprinter} + ) + with LogCapture() as log: + yield crawler.crawl() + assert "Error while processing requests from start()" in str(log) + assert "Spider closed (shutdown)" in str(log) def test_short_timeout(self): args = ( @@ -471,22 +489,112 @@ class TestEngine(TestEngineBase): ) p = subprocess.Popen( args, + stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, ) - def kill_proc(): + try: + _, stderr = p.communicate(timeout=15) + except subprocess.TimeoutExpired: p.kill() p.communicate() - raise AssertionError("Command took too much time to complete") + pytest.fail("Command took too much time to complete") - timer = Timer(15, kill_proc) - try: - timer.start() - _, stderr = p.communicate() - finally: - timer.cancel() + stderr_str = stderr.decode("utf-8") + assert "AttributeError" not in stderr_str, stderr_str + assert "AssertionError" not in stderr_str, stderr_str - assert b"Traceback" not in stderr, stderr + +class TestEngineDownloadAsync: + """Test cases for ExecutionEngine.download_async().""" + + @pytest.fixture + def engine(self) -> ExecutionEngine: + crawler = get_crawler(MySpider) + engine = ExecutionEngine(crawler, lambda _: None) + engine.downloader.close() + engine.downloader = Mock() + engine._slot = Mock() + engine._slot.inprogress = set() + return engine + + @staticmethod + async def _download(engine: ExecutionEngine, request: Request) -> Response: + return await engine.download_async(request) + + @deferred_f_from_coro_f + async def test_download_async_success(self, engine): + """Test basic successful async download of a request.""" + request = Request("http://example.com") + response = Response("http://example.com", body=b"test body") + engine.spider = Mock() + engine.downloader.fetch.return_value = defer.succeed(response) + engine._slot.add_request = Mock() + engine._slot.remove_request = Mock() + + result = await self._download(engine, request) + assert result == response + engine._slot.add_request.assert_called_once_with(request) + engine._slot.remove_request.assert_called_once_with(request) + engine.downloader.fetch.assert_called_once_with(request) + + @deferred_f_from_coro_f + async def test_download_async_redirect(self, engine): + """Test async download with a redirect request.""" + original_request = Request("http://example.com") + redirect_request = Request("http://example.com/redirect") + final_response = Response("http://example.com/redirect", body=b"redirected") + + # First call returns redirect request, second call returns final response + engine.downloader.fetch.side_effect = [ + defer.succeed(redirect_request), + defer.succeed(final_response), + ] + engine.spider = Mock() + engine._slot.add_request = Mock() + engine._slot.remove_request = Mock() + + result = await self._download(engine, original_request) + assert result == final_response + assert engine.downloader.fetch.call_count == 2 + engine._slot.add_request.assert_has_calls( + [call(original_request), call(redirect_request)] + ) + engine._slot.remove_request.assert_has_calls( + [call(original_request), call(redirect_request)] + ) + + @deferred_f_from_coro_f + async def test_download_async_no_spider(self, engine): + """Test async download attempt when no spider is available.""" + request = Request("http://example.com") + engine.spider = None + with pytest.raises(RuntimeError, match="No open spider to crawl:"): + await self._download(engine, request) + + @deferred_f_from_coro_f + async def test_download_async_failure(self, engine): + """Test async download when the downloader raises an exception.""" + request = Request("http://example.com") + error = RuntimeError("Download failed") + engine.spider = Mock() + engine.downloader.fetch.return_value = defer.fail(error) + engine._slot.add_request = Mock() + engine._slot.remove_request = Mock() + + with pytest.raises(RuntimeError, match="Download failed"): + await self._download(engine, request) + engine._slot.add_request.assert_called_once_with(request) + engine._slot.remove_request.assert_called_once_with(request) + + +@pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") +class TestEngineDownload(TestEngineDownloadAsync): + """Test cases for ExecutionEngine.download().""" + + @staticmethod + async def _download(engine: ExecutionEngine, request: Request) -> Response: + return await maybe_deferred_to_future(engine.download(request)) def test_request_scheduled_signal(caplog): @@ -513,7 +621,7 @@ def test_request_scheduled_signal(caplog): engine._start = start() engine._slot = _Slot(False, Mock(), scheduler) - crawler.signals.connect(signal_handler, request_scheduled) + crawler.signals.connect(signal_handler, signals.request_scheduled) keep_request = Request("https://keep.example") engine._schedule_request(keep_request) drop_request = Request("https://drop.example") @@ -522,12 +630,122 @@ def test_request_scheduled_signal(caplog): assert scheduler.enqueued == [keep_request], ( f"{scheduler.enqueued!r} != [{keep_request!r}]" ) - crawler.signals.disconnect(signal_handler, request_scheduled) + crawler.signals.disconnect(signal_handler, signals.request_scheduled) -if __name__ == "__main__": - from twisted.internet import reactor # pylint: disable=ungrouped-imports +class TestEngineCloseSpider: + """Tests for exception handling coverage during close_spider_async().""" - if len(sys.argv) > 1 and sys.argv[1] == "runserver": - start_test_site(debug=True) - reactor.run() + @pytest.fixture + def crawler(self) -> Crawler: + crawler = get_crawler(DefaultSpider) + crawler.spider = crawler._create_spider() + return crawler + + @deferred_f_from_coro_f + async def test_no_slot(self, crawler: Crawler) -> None: + engine = ExecutionEngine(crawler, lambda _: None) + await engine.open_spider_async() + slot = engine._slot + engine._slot = None + with pytest.raises(RuntimeError, match="Engine slot not assigned"): + await engine.close_spider_async() + # close it correctly + engine._slot = slot + await engine.close_spider_async() + + @deferred_f_from_coro_f + async def test_no_spider(self, crawler: Crawler) -> None: + engine = ExecutionEngine(crawler, lambda _: None) + with pytest.raises(RuntimeError, match="Spider not opened"): + await engine.close_spider_async() + + @deferred_f_from_coro_f + async def test_exception_slot( + self, crawler: Crawler, caplog: pytest.LogCaptureFixture + ) -> None: + engine = ExecutionEngine(crawler, lambda _: None) + await engine.open_spider_async() + assert engine._slot + del engine._slot.heartbeat + await engine.close_spider_async() + assert "Slot close failure" in caplog.text + + @deferred_f_from_coro_f + async def test_exception_downloader( + self, crawler: Crawler, caplog: pytest.LogCaptureFixture + ) -> None: + engine = ExecutionEngine(crawler, lambda _: None) + await engine.open_spider_async() + del engine.downloader.slots + await engine.close_spider_async() + assert "Downloader close failure" in caplog.text + + @deferred_f_from_coro_f + async def test_exception_scraper( + self, crawler: Crawler, caplog: pytest.LogCaptureFixture + ) -> None: + engine = ExecutionEngine(crawler, lambda _: None) + await engine.open_spider_async() + engine.scraper.slot = None + await engine.close_spider_async() + assert "Scraper close failure" in caplog.text + + @deferred_f_from_coro_f + async def test_exception_scheduler( + self, crawler: Crawler, caplog: pytest.LogCaptureFixture + ) -> None: + engine = ExecutionEngine(crawler, lambda _: None) + await engine.open_spider_async() + assert engine._slot + del cast("Scheduler", engine._slot.scheduler).dqs + await engine.close_spider_async() + assert "Scheduler close failure" in caplog.text + + @deferred_f_from_coro_f + async def test_exception_signal( + self, crawler: Crawler, caplog: pytest.LogCaptureFixture + ) -> None: + engine = ExecutionEngine(crawler, lambda _: None) + await engine.open_spider_async() + signal_manager = engine.signals + del engine.signals + await engine.close_spider_async() + assert "Error while sending spider_close signal" in caplog.text + # send the spider_closed signal to close various components + await signal_manager.send_catch_log_async( + signal=signals.spider_closed, + spider=engine.spider, + reason="cancelled", + ) + + @deferred_f_from_coro_f + async def test_exception_stats( + self, crawler: Crawler, caplog: pytest.LogCaptureFixture + ) -> None: + engine = ExecutionEngine(crawler, lambda _: None) + await engine.open_spider_async() + del cast("MemoryStatsCollector", crawler.stats).spider_stats + await engine.close_spider_async() + assert "Stats close failure" in caplog.text + + @deferred_f_from_coro_f + async def test_exception_callback( + self, crawler: Crawler, caplog: pytest.LogCaptureFixture + ) -> None: + engine = ExecutionEngine(crawler, lambda _: defer.fail(ValueError())) + await engine.open_spider_async() + await engine.close_spider_async() + assert "Error running spider_closed_callback" in caplog.text + + @deferred_f_from_coro_f + async def test_exception_async_callback( + self, crawler: Crawler, caplog: pytest.LogCaptureFixture + ) -> None: + async def cb(_): + raise ValueError + + engine = ExecutionEngine(crawler, cb) + await engine.open_spider_async() + await engine.close_spider_async() + assert "Error running spider_closed_callback" in caplog.text diff --git a/tests/test_engine_loop.py b/tests/test_engine_loop.py index bfb8eeced..ddf1f1fe0 100644 --- a/tests/test_engine_loop.py +++ b/tests/test_engine_loop.py @@ -4,18 +4,17 @@ from collections import deque from logging import ERROR from typing import TYPE_CHECKING -from testfixtures import LogCapture from twisted.internet.defer import Deferred -from twisted.trial.unittest import TestCase from scrapy import Request, Spider, signals from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future from scrapy.utils.test import get_crawler - -from .mockserver import MockServer -from .test_scheduler import MemoryScheduler +from tests.mockserver.http import MockServer +from tests.test_scheduler import MemoryScheduler if TYPE_CHECKING: + import pytest + from scrapy.http import Response @@ -27,7 +26,7 @@ async def sleep(seconds: float = 0.001) -> None: await maybe_deferred_to_future(deferred) -class TestMain(TestCase): +class TestMain: @deferred_f_from_coro_f async def test_sleep(self): """Neither asynchronous sleeps on Spider.start() nor the equivalent on @@ -88,13 +87,15 @@ class TestMain(TestCase): assert actual_urls == expected_urls, f"{actual_urls=} != {expected_urls=}" @deferred_f_from_coro_f - async def test_close_during_start_iteration(self): + async def test_close_during_start_iteration( + self, caplog: pytest.LogCaptureFixture + ) -> None: class TestSpider(Spider): name = "test" async def start(self): assert self.crawler.engine is not None - await maybe_deferred_to_future(self.crawler.engine.close()) + await self.crawler.engine.close_async() yield Request("data:,a") def parse(self, response): @@ -109,26 +110,26 @@ class TestMain(TestCase): crawler = get_crawler(TestSpider, settings_dict=settings) crawler.signals.connect(track_url, signals.request_reached_downloader) - with LogCapture(level=ERROR) as log: + caplog.clear() + with caplog.at_level(ERROR): await maybe_deferred_to_future(crawler.crawl()) - assert not log.records, f"{log.records=}" - finish_reason = crawler.stats.get_value("finish_reason") - assert finish_reason == "shutdown", f"{finish_reason=}" - expected_urls = [] - assert actual_urls == expected_urls, f"{actual_urls=} != {expected_urls=}" + assert not caplog.records + assert crawler.stats + assert crawler.stats.get_value("finish_reason") == "shutdown" + assert not actual_urls -class TestRequestSendOrder(TestCase): +class TestRequestSendOrder: seconds = 0.1 # increase if flaky @classmethod - def setUpClass(cls): + def setup_class(cls): cls.mockserver = MockServer() cls.mockserver.__enter__() @classmethod - def tearDownClass(cls): + def teardown_class(cls): cls.mockserver.__exit__(None, None, None) # increase if flaky def request(self, num, response_seconds, download_slots, priority=0): @@ -139,7 +140,6 @@ class TestRequestSendOrder(TestCase): def get_num(self, request_or_response: Request | Response): return int(request_or_response.url.rsplit("&", maxsplit=1)[1]) - @deferred_f_from_coro_f async def _test_request_order( self, start_nums, @@ -219,14 +219,12 @@ class TestRequestSendOrder(TestCase): return yield - await maybe_deferred_to_future( - self._test_request_order( - start_nums=nums, - settings={"CONCURRENT_REQUESTS": 1}, - response_seconds=response_seconds, - start_fn=start, - parse_fn=parse, - ) + await self._test_request_order( + start_nums=nums, + settings={"CONCURRENT_REQUESTS": 1}, + response_seconds=response_seconds, + start_fn=start, + parse_fn=parse, ) @deferred_f_from_coro_f @@ -261,17 +259,15 @@ class TestRequestSendOrder(TestCase): return yield - await maybe_deferred_to_future( - self._test_request_order( - start_nums=nums, - settings={ - "CONCURRENT_REQUESTS": 1, - "SCHEDULER_START_MEMORY_QUEUE": "scrapy.squeues.LifoMemoryQueue", - }, - response_seconds=response_seconds, - start_fn=start, - parse_fn=parse, - ) + await self._test_request_order( + start_nums=nums, + settings={ + "CONCURRENT_REQUESTS": 1, + "SCHEDULER_START_MEMORY_QUEUE": "scrapy.squeues.LifoMemoryQueue", + }, + response_seconds=response_seconds, + start_fn=start, + parse_fn=parse, ) @deferred_f_from_coro_f @@ -321,17 +317,15 @@ class TestRequestSendOrder(TestCase): return yield - await maybe_deferred_to_future( - self._test_request_order( - start_nums=nums, - settings={ - "CONCURRENT_REQUESTS": 1, - "SCHEDULER_START_MEMORY_QUEUE": None, - }, - response_seconds=response_seconds, - start_fn=start, - parse_fn=parse, - ) + await self._test_request_order( + start_nums=nums, + settings={ + "CONCURRENT_REQUESTS": 1, + "SCHEDULER_START_MEMORY_QUEUE": None, + }, + response_seconds=response_seconds, + start_fn=start, + parse_fn=parse, ) # Examples from the “Start requests” section of the documentation about @@ -351,14 +345,12 @@ class TestRequestSendOrder(TestCase): request = self.request(num, response_seconds, download_slots) yield request - await maybe_deferred_to_future( - self._test_request_order( - start_nums=start_nums, - cb_nums=cb_nums, - settings={ - "CONCURRENT_REQUESTS": 1, - }, - response_seconds=response_seconds, - start_fn=start, - ) + await self._test_request_order( + start_nums=start_nums, + cb_nums=cb_nums, + settings={ + "CONCURRENT_REQUESTS": 1, + }, + response_seconds=response_seconds, + start_fn=start, ) diff --git a/tests/test_engine_stop_download_bytes.py b/tests/test_engine_stop_download_bytes.py index 2662e45e1..1d7df70eb 100644 --- a/tests/test_engine_stop_download_bytes.py +++ b/tests/test_engine_stop_download_bytes.py @@ -1,7 +1,11 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + from testfixtures import LogCapture -from twisted.internet.defer import inlineCallbacks from scrapy.exceptions import StopDownload +from scrapy.utils.defer import deferred_f_from_coro_f from tests.test_engine import ( AttrsItemsSpider, CrawlerRun, @@ -11,6 +15,9 @@ from tests.test_engine import ( TestEngineBase, ) +if TYPE_CHECKING: + from tests.mockserver.http import MockServer + class BytesReceivedCrawlerRun(CrawlerRun): def bytes_received(self, data, request, spider): @@ -19,8 +26,8 @@ class BytesReceivedCrawlerRun(CrawlerRun): class TestBytesReceivedEngine(TestEngineBase): - @inlineCallbacks - def test_crawler(self): + @deferred_f_from_coro_f + async def test_crawler(self, mockserver: MockServer) -> None: for spider in ( MySpider, DictItemsSpider, @@ -29,12 +36,12 @@ class TestBytesReceivedEngine(TestEngineBase): ): run = BytesReceivedCrawlerRun(spider) with LogCapture() as log: - yield run.run() + await run.run(mockserver) log.check_present( ( "scrapy.core.downloader.handlers.http11", "DEBUG", - f"Download stopped for " + f"Download stopped for " "from signal handler BytesReceivedCrawlerRun.bytes_received", ) ) @@ -42,7 +49,7 @@ class TestBytesReceivedEngine(TestEngineBase): ( "scrapy.core.downloader.handlers.http11", "DEBUG", - f"Download stopped for " + f"Download stopped for " "from signal handler BytesReceivedCrawlerRun.bytes_received", ) ) @@ -50,7 +57,7 @@ class TestBytesReceivedEngine(TestEngineBase): ( "scrapy.core.downloader.handlers.http11", "DEBUG", - f"Download stopped for " + f"Download stopped for " "from signal handler BytesReceivedCrawlerRun.bytes_received", ) ) diff --git a/tests/test_engine_stop_download_headers.py b/tests/test_engine_stop_download_headers.py index 142715927..c01413d4e 100644 --- a/tests/test_engine_stop_download_headers.py +++ b/tests/test_engine_stop_download_headers.py @@ -1,7 +1,11 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + from testfixtures import LogCapture -from twisted.internet.defer import inlineCallbacks from scrapy.exceptions import StopDownload +from scrapy.utils.defer import deferred_f_from_coro_f from tests.test_engine import ( AttrsItemsSpider, CrawlerRun, @@ -11,6 +15,9 @@ from tests.test_engine import ( TestEngineBase, ) +if TYPE_CHECKING: + from tests.mockserver.http import MockServer + class HeadersReceivedCrawlerRun(CrawlerRun): def headers_received(self, headers, body_length, request, spider): @@ -19,8 +26,8 @@ class HeadersReceivedCrawlerRun(CrawlerRun): class TestHeadersReceivedEngine(TestEngineBase): - @inlineCallbacks - def test_crawler(self): + @deferred_f_from_coro_f + async def test_crawler(self, mockserver: MockServer) -> None: for spider in ( MySpider, DictItemsSpider, @@ -29,12 +36,12 @@ class TestHeadersReceivedEngine(TestEngineBase): ): run = HeadersReceivedCrawlerRun(spider) with LogCapture() as log: - yield run.run() + await run.run(mockserver) log.check_present( ( "scrapy.core.downloader.handlers.http11", "DEBUG", - f"Download stopped for from" + f"Download stopped for from" " signal handler HeadersReceivedCrawlerRun.headers_received", ) ) @@ -42,7 +49,7 @@ class TestHeadersReceivedEngine(TestEngineBase): ( "scrapy.core.downloader.handlers.http11", "DEBUG", - f"Download stopped for from signal" + f"Download stopped for from signal" " handler HeadersReceivedCrawlerRun.headers_received", ) ) @@ -50,7 +57,7 @@ class TestHeadersReceivedEngine(TestEngineBase): ( "scrapy.core.downloader.handlers.http11", "DEBUG", - f"Download stopped for from" + f"Download stopped for from" " signal handler HeadersReceivedCrawlerRun.headers_received", ) ) @@ -66,7 +73,7 @@ class TestHeadersReceivedEngine(TestEngineBase): @staticmethod def _assert_visited_urls(run: CrawlerRun) -> None: - must_be_visited = ["/", "/redirect", "/redirected"] + must_be_visited = ["/static/", "/redirect", "/redirected"] urls_visited = {rp[0].url for rp in run.respplug} urls_expected = {run.geturl(p) for p in must_be_visited} assert urls_expected <= urls_visited, ( diff --git a/tests/test_exporters.py b/tests/test_exporters.py index 05e8865bc..5719d5bb0 100644 --- a/tests/test_exporters.py +++ b/tests/test_exporters.py @@ -4,6 +4,7 @@ import marshal import pickle import re import tempfile +from abc import ABC, abstractmethod from datetime import datetime from io import BytesIO from typing import Any @@ -53,7 +54,7 @@ class CustomFieldDataclass: age: int = dataclasses.field(metadata={"serializer": custom_serializer}) -class TestBaseItemExporter: +class TestBaseItemExporter(ABC): item_class: type = MyItem custom_field_item_class: type = CustomFieldItem @@ -62,10 +63,11 @@ class TestBaseItemExporter: self.output = BytesIO() self.ie = self._get_exporter() - def _get_exporter(self, **kwargs): - return BaseItemExporter(**kwargs) + @abstractmethod + def _get_exporter(self, **kwargs) -> BaseItemExporter: + raise NotImplementedError - def _check_output(self): + def _check_output(self): # noqa: B027 pass def _assert_expected_item(self, exported_dict): @@ -83,11 +85,7 @@ class TestBaseItemExporter: def assertItemExportWorks(self, item): self.ie.start_exporting() - try: - self.ie.export_item(item) - except NotImplementedError: - if self.ie.__class__ is not BaseItemExporter: - raise + self.ie.export_item(item) self.ie.finish_exporting() # Delete the item exporter object, so that if it causes the output # file handle to be closed, which should not be the case, follow-up @@ -132,11 +130,6 @@ class TestBaseItemExporter: assert ie.serialize_field(a.get_field_meta("age"), "age", a["age"]) == "24" -class TestBaseItemExporterDataclass(TestBaseItemExporter): - item_class = MyDataClass - custom_field_item_class = CustomFieldDataclass - - class TestPythonItemExporter(TestBaseItemExporter): def _get_exporter(self, **kwargs): return PythonItemExporter(**kwargs) @@ -670,6 +663,9 @@ class TestCustomExporterItem: return str(int(value) + 1) return super().serialize_field(field, name, value) + def export_item(self, item: Any) -> None: + pass + i = self.item_class(name="John", age="22") a = ItemAdapter(i) ie = CustomItemExporter() diff --git a/tests/test_extension_periodic_log.py b/tests/test_extension_periodic_log.py index b86f3c7f2..8d6c22ad7 100644 --- a/tests/test_extension_periodic_log.py +++ b/tests/test_extension_periodic_log.py @@ -1,13 +1,17 @@ from __future__ import annotations import datetime -from typing import Any, Callable +from typing import TYPE_CHECKING, Any from scrapy.extensions.periodic_log import PeriodicLog from scrapy.utils.test import get_crawler from .spiders import MetaSpider +if TYPE_CHECKING: + from collections.abc import Callable + + stats_dump_1 = { "log_count/INFO": 10, "log_count/WARNING": 1, diff --git a/tests/test_extension_statsmailer.py b/tests/test_extension_statsmailer.py new file mode 100644 index 000000000..d2e4d5c00 --- /dev/null +++ b/tests/test_extension_statsmailer.py @@ -0,0 +1,82 @@ +from unittest.mock import MagicMock + +import pytest + +from scrapy import signals +from scrapy.exceptions import NotConfigured +from scrapy.extensions import statsmailer +from scrapy.mail import MailSender +from scrapy.signalmanager import SignalManager +from scrapy.statscollectors import StatsCollector +from scrapy.utils.spider import DefaultSpider + + +@pytest.fixture +def dummy_stats(): + class DummyStats(StatsCollector): + def __init__(self): + # pylint: disable=super-init-not-called + self._stats = {"global_item_scraped_count": 42} + + def get_stats(self): + return {"item_scraped_count": 10, **self._stats} + + return DummyStats() + + +def test_from_crawler_without_recipients_raises_notconfigured(): + crawler = MagicMock() + crawler.settings.getlist.return_value = [] + crawler.stats = MagicMock() + + with pytest.raises(NotConfigured): + statsmailer.StatsMailer.from_crawler(crawler) + + +def test_from_crawler_with_recipients_initializes_extension(dummy_stats, monkeypatch): + crawler = MagicMock() + crawler.settings.getlist.return_value = ["test@example.com"] + crawler.stats = dummy_stats + crawler.signals = SignalManager(crawler) + + mailer = MagicMock(spec=MailSender) + monkeypatch.setattr(statsmailer.MailSender, "from_crawler", lambda _: mailer) + + ext = statsmailer.StatsMailer.from_crawler(crawler) + + assert isinstance(ext, statsmailer.StatsMailer) + assert ext.recipients == ["test@example.com"] + assert ext.mail is mailer + + +def test_from_crawler_connects_spider_closed_signal(dummy_stats, monkeypatch): + crawler = MagicMock() + crawler.settings.getlist.return_value = ["test@example.com"] + crawler.stats = dummy_stats + crawler.signals = SignalManager(crawler) + + mailer = MagicMock(spec=MailSender) + monkeypatch.setattr(statsmailer.MailSender, "from_crawler", lambda _: mailer) + + statsmailer.StatsMailer.from_crawler(crawler) + + connected = crawler.signals.send_catch_log( + signals.spider_closed, spider=DefaultSpider(name="dummy") + ) + assert connected is not None + + +def test_spider_closed_sends_email(dummy_stats): + recipients = ["test@example.com"] + mail = MagicMock(spec=MailSender) + ext = statsmailer.StatsMailer(dummy_stats, recipients, mail) + + spider = DefaultSpider(name="dummy") + ext.spider_closed(spider) + + args, _ = mail.send.call_args + to, subject, body = args + assert to == recipients + assert "Scrapy stats for: dummy" in subject + assert "global_item_scraped_count" in body + assert "item_scraped_count" in body diff --git a/tests/test_extension_telnet.py b/tests/test_extension_telnet.py index f9e54cb28..6b4ad450f 100644 --- a/tests/test_extension_telnet.py +++ b/tests/test_extension_telnet.py @@ -2,13 +2,12 @@ import pytest from twisted.conch.telnet import ITelnetProtocol from twisted.cred import credentials from twisted.internet.defer import inlineCallbacks -from twisted.trial import unittest from scrapy.extensions.telnet import TelnetConsole from scrapy.utils.test import get_crawler -class TestTelnetExtension(unittest.TestCase): +class TestTelnetExtension: def _get_console_and_portal(self, settings=None): crawler = get_crawler(settings_dict=settings) console = TelnetConsole(crawler) diff --git a/tests/test_feedexport.py b/tests/test_feedexport.py index f8f3eb22a..7eb9f78e8 100644 --- a/tests/test_feedexport.py +++ b/tests/test_feedexport.py @@ -5,7 +5,9 @@ import csv import gzip import json import lzma +import marshal import os +import pickle import random import shutil import string @@ -18,17 +20,17 @@ from io import BytesIO from logging import getLogger from pathlib import Path from string import ascii_letters, digits -from typing import TYPE_CHECKING, Any +from typing import IO, TYPE_CHECKING, Any from unittest import mock from urllib.parse import quote, urljoin from urllib.request import pathname2url import lxml.etree import pytest +from packaging.version import Version from testfixtures import LogCapture from twisted.internet import defer from twisted.internet.defer import inlineCallbacks -from twisted.trial import unittest from w3lib.url import file_uri_to_path, path_to_file_uri from zope.interface import implementer from zope.interface.verify import verifyObject @@ -52,7 +54,8 @@ from scrapy.settings import Settings from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future from scrapy.utils.python import to_unicode from scrapy.utils.test import get_crawler -from tests.mockserver import MockFTPServer, MockServer +from tests.mockserver.ftp import MockFTPServer +from tests.mockserver.http import MockServer from tests.spiders import ItemSpider if TYPE_CHECKING: @@ -79,7 +82,7 @@ def mock_google_cloud_storage() -> tuple[Any, Any, Any]: """Creates autospec mocks for google-cloud-storage Client, Bucket and Blob classes and set their proper return values. """ - from google.cloud.storage import Blob, Bucket, Client + from google.cloud.storage import Blob, Bucket, Client # noqa: PLC0415 client_mock = mock.create_autospec(Client) @@ -155,8 +158,13 @@ class TestFileFeedStorage: finally: path.unlink() + def test_preserves_windows_path_without_file_scheme(self): + path = r"C:\Users\user\Desktop\test.txt" + storage = FileFeedStorage(path) + assert storage.path == path -class TestFTPFeedStorage(unittest.TestCase): + +class TestFTPFeedStorage: def get_test_spider(self, settings=None): class TestSpider(scrapy.Spider): name = "test_spider" @@ -231,6 +239,11 @@ class TestFTPFeedStorage(unittest.TestCase): assert st.password == string.punctuation +class MyBlockingFeedStorage(BlockingFeedStorage): + def _store_in_thread(self, file: IO[bytes]) -> None: + return + + class TestBlockingFeedStorage: def get_test_spider(self, settings=None): class TestSpider(scrapy.Spider): @@ -240,14 +253,14 @@ class TestBlockingFeedStorage: return TestSpider.from_crawler(crawler) def test_default_temp_dir(self): - b = BlockingFeedStorage() + b = MyBlockingFeedStorage() storage_file = b.open(self.get_test_spider()) storage_dir = Path(storage_file.name).parent assert str(storage_dir) == tempfile.gettempdir() def test_temp_file(self, tmp_path): - b = BlockingFeedStorage() + b = MyBlockingFeedStorage() spider = self.get_test_spider({"FEED_TEMPDIR": str(tmp_path)}) storage_file = b.open(spider) @@ -255,7 +268,7 @@ class TestBlockingFeedStorage: assert storage_dir == tmp_path def test_invalid_folder(self, tmp_path): - b = BlockingFeedStorage() + b = MyBlockingFeedStorage() invalid_path = tmp_path / "invalid_path" spider = self.get_test_spider({"FEED_TEMPDIR": str(invalid_path)}) @@ -265,7 +278,7 @@ class TestBlockingFeedStorage: @pytest.mark.requires_boto3 -class TestS3FeedStorage(unittest.TestCase): +class TestS3FeedStorage: def test_parse_credentials(self): aws_credentials = { "AWS_ACCESS_KEY_ID": "settings_key", @@ -494,10 +507,10 @@ class TestS3FeedStorage(unittest.TestCase): assert "S3 does not support appending to files" in str(log) -class TestGCSFeedStorage(unittest.TestCase): +class TestGCSFeedStorage: def test_parse_settings(self): try: - from google.cloud.storage import Client # noqa: F401 + from google.cloud.storage import Client # noqa: F401,PLC0415 except ImportError: pytest.skip("GCSFeedStorage requires google-cloud-storage") @@ -511,7 +524,7 @@ class TestGCSFeedStorage(unittest.TestCase): def test_parse_empty_acl(self): try: - from google.cloud.storage import Client # noqa: F401 + from google.cloud.storage import Client # noqa: F401,PLC0415 except ImportError: pytest.skip("GCSFeedStorage requires google-cloud-storage") @@ -528,7 +541,7 @@ class TestGCSFeedStorage(unittest.TestCase): @deferred_f_from_coro_f async def test_store(self): try: - from google.cloud.storage import Client # noqa: F401 + from google.cloud.storage import Client # noqa: F401,PLC0415 except ImportError: pytest.skip("GCSFeedStorage requires google-cloud-storage") @@ -648,7 +661,7 @@ class LogOnStoreFileStorage: file.close() -class TestFeedExportBase(ABC, unittest.TestCase): +class TestFeedExportBase(ABC): mockserver: MockServer class MyItem(scrapy.Item): @@ -666,18 +679,18 @@ class TestFeedExportBase(ABC, unittest.TestCase): return Path(self.temp_dir, inter_dir, filename) @classmethod - def setUpClass(cls): + def setup_class(cls): cls.mockserver = MockServer() cls.mockserver.__enter__() @classmethod - def tearDownClass(cls): + def teardown_class(cls): cls.mockserver.__exit__(None, None, None) - def setUp(self): + def setup_method(self): self.temp_dir = tempfile.mkdtemp() - def tearDown(self): + def teardown_method(self): shutil.rmtree(self.temp_dir, ignore_errors=True) async def exported_data( @@ -722,7 +735,7 @@ class TestFeedExportBase(ABC, unittest.TestCase): await self.assertExportedMarshal(items, rows, settings) await self.assertExportedMultiple(items, rows, settings) - async def assertExportedCsv( + async def assertExportedCsv( # noqa: B027 self, items: Iterable[Any], header: Iterable[str], @@ -731,7 +744,7 @@ class TestFeedExportBase(ABC, unittest.TestCase): ) -> None: pass - async def assertExportedJsonLines( + async def assertExportedJsonLines( # noqa: B027 self, items: Iterable[Any], rows: Iterable[dict[str, Any]], @@ -739,7 +752,7 @@ class TestFeedExportBase(ABC, unittest.TestCase): ) -> None: pass - async def assertExportedXml( + async def assertExportedXml( # noqa: B027 self, items: Iterable[Any], rows: Iterable[dict[str, Any]], @@ -747,7 +760,7 @@ class TestFeedExportBase(ABC, unittest.TestCase): ) -> None: pass - async def assertExportedMultiple( + async def assertExportedMultiple( # noqa: B027 self, items: Iterable[Any], rows: Iterable[dict[str, Any]], @@ -755,7 +768,7 @@ class TestFeedExportBase(ABC, unittest.TestCase): ) -> None: pass - async def assertExportedPickle( + async def assertExportedPickle( # noqa: B027 self, items: Iterable[Any], rows: Iterable[dict[str, Any]], @@ -763,7 +776,7 @@ class TestFeedExportBase(ABC, unittest.TestCase): ) -> None: pass - async def assertExportedMarshal( + async def assertExportedMarshal( # noqa: B027 self, items: Iterable[Any], rows: Iterable[dict[str, Any]], @@ -966,7 +979,6 @@ class TestFeedExport(TestFeedExportBase): ) data = await self.exported_data(items, settings) expected = [{k: v for k, v in row.items() if v} for row in rows] - import pickle result = self._load_until_eof(data["pickle"], load_func=pickle.load) assert result == expected @@ -987,7 +999,6 @@ class TestFeedExport(TestFeedExportBase): ) data = await self.exported_data(items, settings) expected = [{k: v for k, v in row.items() if v} for row in rows] - import marshal result = self._load_until_eof(data["marshal"], load_func=marshal.load) assert result == expected @@ -1706,7 +1717,6 @@ class TestFeedExport(TestFeedExportBase): with LogCapture() as log: await self.exported_data(items, settings) - print(log) for fmt in ["json", "xml", "csv"]: assert f"Stored {fmt} feed (2 items)" in str(log) @@ -1727,7 +1737,6 @@ class TestFeedExport(TestFeedExportBase): with LogCapture() as log: await self.exported_data(items, settings) - print(log) for fmt in ["json", "xml", "csv"]: assert f"Error storing {fmt} feed (2 items)" in str(log) @@ -2290,9 +2299,6 @@ class TestFeedPostProcessedExports(TestFeedExportBase): @deferred_f_from_coro_f async def test_exports_compatibility_with_postproc(self): - import marshal - import pickle - filename_to_expected = { self._named_tempfile("csv"): b"foo\r\nbar\r\n", self._named_tempfile("json"): b'[\n{"foo": "bar"}\n]', @@ -2474,7 +2480,6 @@ class TestBatchDeliveries(TestFeedExportBase): batch_size = Settings(settings).getint("FEED_EXPORT_BATCH_ITEM_COUNT") rows = [{k: v for k, v in row.items() if v} for row in rows] data = await self.exported_data(items, settings) - import pickle for batch in data["pickle"]: got_batch = self._load_until_eof(batch, load_func=pickle.load) @@ -2495,7 +2500,6 @@ class TestBatchDeliveries(TestFeedExportBase): batch_size = Settings(settings).getint("FEED_EXPORT_BATCH_ITEM_COUNT") rows = [{k: v for k, v in row.items() if v} for row in rows] data = await self.exported_data(items, settings) - import marshal for batch in data["marshal"]: got_batch = self._load_until_eof(batch, load_func=marshal.load) @@ -2624,7 +2628,7 @@ class TestBatchDeliveries(TestFeedExportBase): } data = await self.exported_data(items, settings) for fmt, expected in formats.items(): - for expected_batch, got_batch in zip(expected, data[fmt]): + for expected_batch, got_batch in zip(expected, data[fmt], strict=False): assert got_batch == expected_batch @deferred_f_from_coro_f @@ -2648,7 +2652,7 @@ class TestBatchDeliveries(TestFeedExportBase): } data = await self.exported_data(items, settings) for fmt, expected in formats.items(): - for expected_batch, got_batch in zip(expected, data[fmt]): + for expected_batch, got_batch in zip(expected, data[fmt], strict=False): assert got_batch == expected_batch @deferred_f_from_coro_f @@ -2704,9 +2708,8 @@ class TestBatchDeliveries(TestFeedExportBase): stubs = [] def open(self, *args, **kwargs): - from botocore import __version__ as botocore_version - from botocore.stub import ANY, Stubber - from packaging.version import Version + from botocore import __version__ as botocore_version # noqa: PLC0415 + from botocore.stub import ANY, Stubber # noqa: PLC0415 expected_params = { "Body": ANY, @@ -2902,10 +2905,9 @@ class TestURIParams(ABC): match="The `FEED_URI` and `FEED_FORMAT` settings have been deprecated", ): crawler = get_crawler(settings_dict=settings) - feed_exporter = FeedExporter.from_crawler(crawler) else: crawler = get_crawler(settings_dict=settings) - feed_exporter = FeedExporter.from_crawler(crawler) + feed_exporter = crawler.get_extension(FeedExporter) return crawler, feed_exporter def test_default(self): diff --git a/tests/test_http2_client_protocol.py b/tests/test_http2_client_protocol.py index 80edd50d6..dcc067ec9 100644 --- a/tests/test_http2_client_protocol.py +++ b/tests/test_http2_client_protocol.py @@ -3,16 +3,15 @@ from __future__ import annotations import json import random import re -import shutil import string from ipaddress import IPv4Address from pathlib import Path -from tempfile import mkdtemp -from typing import TYPE_CHECKING, Any, Callable +from typing import TYPE_CHECKING, Any, cast from unittest import mock from urllib.parse import urlencode import pytest +from pytest_twisted import async_yield_fixture from twisted.internet.defer import ( CancelledError, Deferred, @@ -20,9 +19,8 @@ from twisted.internet.defer import ( inlineCallbacks, ) from twisted.internet.endpoints import SSL4ClientEndpoint, SSL4ServerEndpoint -from twisted.internet.error import TimeoutError +from twisted.internet.error import TimeoutError as TxTimeoutError from twisted.internet.ssl import Certificate, PrivateCertificate, optionsForClientTLS -from twisted.trial.unittest import TestCase from twisted.web.client import URI, ResponseFailed from twisted.web.http import H2_ENABLED from twisted.web.http import Request as TxRequest @@ -37,10 +35,18 @@ from scrapy.utils.defer import ( deferred_from_coro, maybe_deferred_to_future, ) -from tests.mockserver import LeafResource, Status, ssl_context_factory +from tests.mockserver.http_resources import LeafResource, Status +from tests.mockserver.utils import ssl_context_factory if TYPE_CHECKING: - from collections.abc import Coroutine + from collections.abc import AsyncGenerator, Callable, Coroutine, Generator + + from scrapy.core.http2.protocol import H2ClientProtocol + + +pytestmark = pytest.mark.skipif( + not H2_ENABLED, reason="HTTP/2 support in Twisted is not enabled" +) def generate_random_string(size: int) -> str: @@ -178,24 +184,23 @@ class RequestHeaders(LeafResource): return bytes(json.dumps(headers), "utf-8") -def get_client_certificate( - key_file: Path, certificate_file: Path -) -> PrivateCertificate: - pem = key_file.read_text(encoding="utf-8") + certificate_file.read_text( - encoding="utf-8" - ) - return PrivateCertificate.loadPEM(pem) +def make_request_dfd(client: H2ClientProtocol, request: Request) -> Deferred[Response]: + return client.request(request, DummySpider()) -@pytest.mark.skipif(not H2_ENABLED, reason="HTTP/2 support in Twisted is not enabled") -class TestHttps2ClientProtocol(TestCase): +async def make_request(client: H2ClientProtocol, request: Request) -> Response: + return await maybe_deferred_to_future(make_request_dfd(client, request)) + + +class TestHttps2ClientProtocol: scheme = "https" + host = "localhost" key_file = Path(__file__).parent / "keys" / "localhost.key" certificate_file = Path(__file__).parent / "keys" / "localhost.crt" - def _init_resource(self): - self.temp_directory = mkdtemp() - r = File(self.temp_directory) + @pytest.fixture + def site(self, tmp_path): + r = File(str(tmp_path)) r.putChild(b"get-data-html-small", GetDataHtmlSmall()) r.putChild(b"get-data-html-large", GetDataHtmlLarge()) @@ -208,72 +213,65 @@ class TestHttps2ClientProtocol(TestCase): r.putChild(b"query-params", QueryParams()) r.putChild(b"timeout", TimeoutResponse()) r.putChild(b"request-headers", RequestHeaders()) - return r + return Site(r, timeout=None) - @inlineCallbacks - def setUp(self): + @async_yield_fixture + async def server_port(self, site: Site) -> AsyncGenerator[int]: from twisted.internet import reactor - # Initialize resource tree - root = self._init_resource() - self.site = Site(root, timeout=None) - - # Start server for testing - self.hostname = "localhost" context_factory = ssl_context_factory( str(self.key_file), str(self.certificate_file) ) - server_endpoint = SSL4ServerEndpoint( - reactor, 0, context_factory, interface=self.hostname + reactor, 0, context_factory, interface=self.host ) - self.server = yield server_endpoint.listen(self.site) - self.port_number = self.server.getHost().port + server = await server_endpoint.listen(site) + + yield server.getHost().port + + await server.stopListening() + + @pytest.fixture + def client_certificate(self) -> PrivateCertificate: + pem = self.key_file.read_text( + encoding="utf-8" + ) + self.certificate_file.read_text(encoding="utf-8") + return PrivateCertificate.loadPEM(pem) + + @async_yield_fixture + async def client( + self, server_port: int, client_certificate: PrivateCertificate + ) -> AsyncGenerator[H2ClientProtocol]: + from twisted.internet import reactor + + from scrapy.core.http2.protocol import H2ClientFactory # noqa: PLC0415 - # Connect H2 client with server - self.client_certificate = get_client_certificate( - self.key_file, self.certificate_file - ) client_options = optionsForClientTLS( - hostname=self.hostname, - trustRoot=self.client_certificate, + hostname=self.host, + trustRoot=client_certificate, acceptableProtocols=[b"h2"], ) - uri = URI.fromBytes(bytes(self.get_url("/"), "utf-8")) - - self.conn_closed_deferred = Deferred() - - from scrapy.core.http2.protocol import H2ClientFactory - - h2_client_factory = H2ClientFactory(uri, Settings(), self.conn_closed_deferred) + uri = URI.fromBytes(bytes(self.get_url(server_port, "/"), "utf-8")) + h2_client_factory = H2ClientFactory(uri, Settings(), Deferred()) client_endpoint = SSL4ClientEndpoint( - reactor, self.hostname, self.port_number, client_options + reactor, self.host, server_port, client_options ) - self.client = yield client_endpoint.connect(h2_client_factory) + client = await client_endpoint.connect(h2_client_factory) - @inlineCallbacks - def tearDown(self): - if self.client.connected: - yield self.client.transport.loseConnection() - yield self.client.transport.abortConnection() - yield self.server.stopListening() - shutil.rmtree(self.temp_directory) - self.conn_closed_deferred = None + yield client - def get_url(self, path: str) -> str: + if client.connected: + client.transport.loseConnection() + client.transport.abortConnection() + + def get_url(self, portno: int, path: str) -> str: """ :param path: Should have / at the starting compulsorily if not empty :return: Complete url """ assert len(path) > 0 assert path[0] == "/" or path[0] == "&" - return f"{self.scheme}://{self.hostname}:{self.port_number}{path}" - - async def make_request(self, request: Request) -> Response: - return await maybe_deferred_to_future(self.make_request_dfd(request)) - - def make_request_dfd(self, request: Request) -> Deferred[Response]: - return self.client.request(request, DummySpider()) + return f"{self.scheme}://{self.host}:{portno}{path}" @staticmethod async def _check_repeat( @@ -287,9 +285,13 @@ class TestHttps2ClientProtocol(TestCase): await maybe_deferred_to_future(DeferredList(d_list, fireOnOneErrback=True)) async def _check_GET( - self, request: Request, expected_body: bytes, expected_status: int + self, + client: H2ClientProtocol, + request: Request, + expected_body: bytes, + expected_status: int, ) -> None: - response = await self.make_request(request) + response = await make_request(client, request) assert response.status == expected_status assert response.body == expected_body assert response.request == request @@ -300,43 +302,62 @@ class TestHttps2ClientProtocol(TestCase): assert len(response.body) == content_length @deferred_f_from_coro_f - async def test_GET_small_body(self): - request = Request(self.get_url("/get-data-html-small")) - await self._check_GET(request, Data.HTML_SMALL, 200) + async def test_GET_small_body( + self, server_port: int, client: H2ClientProtocol + ) -> None: + request = Request(self.get_url(server_port, "/get-data-html-small")) + await self._check_GET(client, request, Data.HTML_SMALL, 200) @deferred_f_from_coro_f - async def test_GET_large_body(self): - request = Request(self.get_url("/get-data-html-large")) - await self._check_GET(request, Data.HTML_LARGE, 200) + async def test_GET_large_body( + self, server_port: int, client: H2ClientProtocol + ) -> None: + request = Request(self.get_url(server_port, "/get-data-html-large")) + await self._check_GET(client, request, Data.HTML_LARGE, 200) async def _check_GET_x10( - self, request: Request, expected_body: bytes, expected_status: int + self, + client: H2ClientProtocol, + request: Request, + expected_body: bytes, + expected_status: int, ) -> None: async def get_coro() -> None: - await self._check_GET(request, expected_body, expected_status) + await self._check_GET(client, request, expected_body, expected_status) await self._check_repeat(get_coro, 10) @deferred_f_from_coro_f - async def test_GET_small_body_x10(self): + async def test_GET_small_body_x10( + self, server_port: int, client: H2ClientProtocol + ) -> None: await self._check_GET_x10( - Request(self.get_url("/get-data-html-small")), Data.HTML_SMALL, 200 + client, + Request(self.get_url(server_port, "/get-data-html-small")), + Data.HTML_SMALL, + 200, ) @deferred_f_from_coro_f - async def test_GET_large_body_x10(self): + async def test_GET_large_body_x10( + self, server_port: int, client: H2ClientProtocol + ) -> None: await self._check_GET_x10( - Request(self.get_url("/get-data-html-large")), Data.HTML_LARGE, 200 + client, + Request(self.get_url(server_port, "/get-data-html-large")), + Data.HTML_LARGE, + 200, ) + @staticmethod async def _check_POST_json( - self, + client: H2ClientProtocol, request: Request, expected_request_body: dict[str, str], expected_extra_data: str, expected_status: int, ) -> None: - response = await self.make_request(request) + response = await make_request(client, request) assert response.status == expected_status assert response.request == request @@ -369,22 +390,30 @@ class TestHttps2ClientProtocol(TestCase): assert request_headers[k_str] == str(v[0], "utf-8") @deferred_f_from_coro_f - async def test_POST_small_json(self): + async def test_POST_small_json( + self, server_port: int, client: H2ClientProtocol + ) -> None: request = JsonRequest( - url=self.get_url("/post-data-json-small"), + url=self.get_url(server_port, "/post-data-json-small"), method="POST", data=Data.JSON_SMALL, ) - await self._check_POST_json(request, Data.JSON_SMALL, Data.EXTRA_SMALL, 200) + await self._check_POST_json( + client, request, Data.JSON_SMALL, Data.EXTRA_SMALL, 200 + ) @deferred_f_from_coro_f - async def test_POST_large_json(self): + async def test_POST_large_json( + self, server_port: int, client: H2ClientProtocol + ) -> None: request = JsonRequest( - url=self.get_url("/post-data-json-large"), + url=self.get_url(server_port, "/post-data-json-large"), method="POST", data=Data.JSON_LARGE, ) - await self._check_POST_json(request, Data.JSON_LARGE, Data.EXTRA_LARGE, 200) + await self._check_POST_json( + client, request, Data.JSON_LARGE, Data.EXTRA_LARGE, 200 + ) async def _check_POST_json_x10(self, *args, **kwargs): async def get_coro() -> None: @@ -393,48 +422,63 @@ class TestHttps2ClientProtocol(TestCase): await self._check_repeat(get_coro, 10) @deferred_f_from_coro_f - async def test_POST_small_json_x10(self): + async def test_POST_small_json_x10( + self, server_port: int, client: H2ClientProtocol + ) -> None: request = JsonRequest( - url=self.get_url("/post-data-json-small"), + url=self.get_url(server_port, "/post-data-json-small"), method="POST", data=Data.JSON_SMALL, ) - await self._check_POST_json_x10(request, Data.JSON_SMALL, Data.EXTRA_SMALL, 200) + await self._check_POST_json_x10( + client, request, Data.JSON_SMALL, Data.EXTRA_SMALL, 200 + ) @deferred_f_from_coro_f - async def test_POST_large_json_x10(self): + async def test_POST_large_json_x10( + self, server_port: int, client: H2ClientProtocol + ) -> None: request = JsonRequest( - url=self.get_url("/post-data-json-large"), + url=self.get_url(server_port, "/post-data-json-large"), method="POST", data=Data.JSON_LARGE, ) - await self._check_POST_json_x10(request, Data.JSON_LARGE, Data.EXTRA_LARGE, 200) + await self._check_POST_json_x10( + client, request, Data.JSON_LARGE, Data.EXTRA_LARGE, 200 + ) @inlineCallbacks - def test_invalid_negotiated_protocol(self): + def test_invalid_negotiated_protocol( + self, server_port: int, client: H2ClientProtocol + ) -> Generator[Deferred[Any], Any, None]: with mock.patch( "scrapy.core.http2.protocol.PROTOCOL_NAME", return_value=b"not-h2" ): - request = Request(url=self.get_url("/status?n=200")) + request = Request(url=self.get_url(server_port, "/status?n=200")) with pytest.raises(ResponseFailed): - yield self.make_request_dfd(request) + yield make_request_dfd(client, request) @inlineCallbacks - def test_cancel_request(self): - request = Request(url=self.get_url("/get-data-html-large")) - d = self.make_request_dfd(request) + def test_cancel_request( + self, server_port: int, client: H2ClientProtocol + ) -> Generator[Deferred[Any], Any, None]: + request = Request(url=self.get_url(server_port, "/get-data-html-large")) + d = make_request_dfd(client, request) d.cancel() - response = yield d + response = cast("Response", (yield d)) assert response.status == 499 assert response.request == request @deferred_f_from_coro_f - async def test_download_maxsize_exceeded(self): + async def test_download_maxsize_exceeded( + self, server_port: int, client: H2ClientProtocol + ) -> None: request = Request( - url=self.get_url("/get-data-html-large"), meta={"download_maxsize": 1000} + url=self.get_url(server_port, "/get-data-html-large"), + meta={"download_maxsize": 1000}, ) with pytest.raises(CancelledError) as exc_info: - await self.make_request(request) + await make_request(client, request) error_pattern = re.compile( rf"Cancelling download of {request.url}: received response " rf"size \(\d*\) larger than download max size \(1000\)" @@ -442,14 +486,16 @@ class TestHttps2ClientProtocol(TestCase): assert len(re.findall(error_pattern, str(exc_info.value))) == 1 @inlineCallbacks - def test_received_dataloss_response(self): + def test_received_dataloss_response( + self, server_port: int, client: H2ClientProtocol + ) -> Generator[Deferred[Any], Any, None]: """In case when value of Header Content-Length != len(Received Data) ProtocolError is raised""" - from h2.exceptions import InvalidBodyLengthError + from h2.exceptions import InvalidBodyLengthError # noqa: PLC0415 - request = Request(url=self.get_url("/dataloss")) + request = Request(url=self.get_url(server_port, "/dataloss")) with pytest.raises(ResponseFailed) as exc_info: - yield self.make_request_dfd(request) + yield make_request_dfd(client, request) assert len(exc_info.value.reasons) > 0 assert any( isinstance(error, InvalidBodyLengthError) @@ -457,42 +503,62 @@ class TestHttps2ClientProtocol(TestCase): ) @deferred_f_from_coro_f - async def test_missing_content_length_header(self): - request = Request(url=self.get_url("/no-content-length-header")) - response = await self.make_request(request) + async def test_missing_content_length_header( + self, server_port: int, client: H2ClientProtocol + ) -> None: + request = Request(url=self.get_url(server_port, "/no-content-length-header")) + response = await make_request(client, request) assert response.status == 200 assert response.body == Data.NO_CONTENT_LENGTH assert response.request == request assert "Content-Length" not in response.headers async def _check_log_warnsize( - self, request: Request, warn_pattern: re.Pattern[str], expected_body: bytes + self, + client: H2ClientProtocol, + request: Request, + warn_pattern: re.Pattern[str], + expected_body: bytes, + caplog: pytest.LogCaptureFixture, ) -> None: - with self.assertLogs("scrapy.core.http2.stream", level="WARNING") as cm: - response = await self.make_request(request) - assert response.status == 200 - assert response.request == request - assert response.body == expected_body + with caplog.at_level("WARNING", "scrapy.core.http2.stream"): + response = await make_request(client, request) + assert response.status == 200 + assert response.request == request + assert response.body == expected_body - # Check the warning is raised only once for this request - assert sum(len(re.findall(warn_pattern, log)) for log in cm.output) == 1 + # Check the warning is raised only once for this request + assert len(re.findall(warn_pattern, caplog.text)) == 1 @deferred_f_from_coro_f - async def test_log_expected_warnsize(self): + async def test_log_expected_warnsize( + self, + server_port: int, + client: H2ClientProtocol, + caplog: pytest.LogCaptureFixture, + ) -> None: request = Request( - url=self.get_url("/get-data-html-large"), meta={"download_warnsize": 1000} + url=self.get_url(server_port, "/get-data-html-large"), + meta={"download_warnsize": 1000}, ) warn_pattern = re.compile( rf"Expected response size \(\d*\) larger than " rf"download warn size \(1000\) in request {request}" ) - await self._check_log_warnsize(request, warn_pattern, Data.HTML_LARGE) + await self._check_log_warnsize( + client, request, warn_pattern, Data.HTML_LARGE, caplog + ) @deferred_f_from_coro_f - async def test_log_received_warnsize(self): + async def test_log_received_warnsize( + self, + server_port: int, + client: H2ClientProtocol, + caplog: pytest.LogCaptureFixture, + ) -> None: request = Request( - url=self.get_url("/no-content-length-header"), + url=self.get_url(server_port, "/no-content-length-header"), meta={"download_warnsize": 10}, ) warn_pattern = re.compile( @@ -500,23 +566,32 @@ class TestHttps2ClientProtocol(TestCase): rf"warn size \(10\) in request {request}" ) - await self._check_log_warnsize(request, warn_pattern, Data.NO_CONTENT_LENGTH) + await self._check_log_warnsize( + client, request, warn_pattern, Data.NO_CONTENT_LENGTH, caplog + ) @deferred_f_from_coro_f - async def test_max_concurrent_streams(self): + async def test_max_concurrent_streams( + self, server_port: int, client: H2ClientProtocol + ) -> None: """Send 500 requests at one to check if we can handle very large number of request. """ async def get_coro() -> None: await self._check_GET( - Request(self.get_url("/get-data-html-small")), Data.HTML_SMALL, 200 + client, + Request(self.get_url(server_port, "/get-data-html-small")), + Data.HTML_SMALL, + 200, ) await self._check_repeat(get_coro, 500) @inlineCallbacks - def test_inactive_stream(self): + def test_inactive_stream( + self, server_port: int, client: H2ClientProtocol + ) -> Generator[Deferred[Any], Any, None]: """Here we send 110 requests considering the MAX_CONCURRENT_STREAMS by default is 100. After sending the first 100 requests we close the connection.""" @@ -525,7 +600,7 @@ class TestHttps2ClientProtocol(TestCase): def assert_inactive_stream(failure): assert failure.check(ResponseFailed) is not None - from scrapy.core.http2.stream import InactiveStreamClosed + from scrapy.core.http2.stream import InactiveStreamClosed # noqa: PLC0415 assert any( isinstance(e, InactiveStreamClosed) for e in failure.value.reasons @@ -533,38 +608,47 @@ class TestHttps2ClientProtocol(TestCase): # Send 100 request (we do not check the result) for _ in range(100): - d = self.make_request_dfd(Request(self.get_url("/get-data-html-small"))) + d = make_request_dfd( + client, Request(self.get_url(server_port, "/get-data-html-small")) + ) d.addBoth(lambda _: None) d_list.append(d) # Now send 10 extra request and save the response deferred in a list for _ in range(10): - d = self.make_request_dfd(Request(self.get_url("/get-data-html-small"))) + d = make_request_dfd( + client, Request(self.get_url(server_port, "/get-data-html-small")) + ) d.addCallback(lambda _: pytest.fail("This request should have failed")) d.addErrback(assert_inactive_stream) d_list.append(d) # Close the connection now to fire all the extra 10 requests errback # with InactiveStreamClosed - self.client.transport.loseConnection() + assert client.transport + client.transport.loseConnection() yield DeferredList(d_list, consumeErrors=True, fireOnOneErrback=True) @deferred_f_from_coro_f - async def test_invalid_request_type(self): + async def test_invalid_request_type(self, client: H2ClientProtocol): with pytest.raises(TypeError): - await self.make_request("https://InvalidDataTypePassed.com") + await make_request(client, "https://InvalidDataTypePassed.com") # type: ignore[arg-type] @deferred_f_from_coro_f - async def test_query_parameters(self): + async def test_query_parameters( + self, server_port: int, client: H2ClientProtocol + ) -> None: params = { "a": generate_random_string(20), "b": generate_random_string(20), "c": generate_random_string(20), "d": generate_random_string(20), } - request = Request(self.get_url(f"/query-params?{urlencode(params)}")) - response = await self.make_request(request) + request = Request( + self.get_url(server_port, f"/query-params?{urlencode(params)}") + ) + response = await make_request(client, request) content_encoding_header = response.headers[b"Content-Encoding"] assert content_encoding_header is not None content_encoding = str(content_encoding_header, "utf-8") @@ -572,67 +656,83 @@ class TestHttps2ClientProtocol(TestCase): assert data == params @deferred_f_from_coro_f - async def test_status_codes(self): + async def test_status_codes( + self, server_port: int, client: H2ClientProtocol + ) -> None: for status in [200, 404]: - request = Request(self.get_url(f"/status?n={status}")) - response = await self.make_request(request) + request = Request(self.get_url(server_port, f"/status?n={status}")) + response = await make_request(client, request) assert response.status == status @deferred_f_from_coro_f - async def test_response_has_correct_certificate_ip_address(self): - request = Request(self.get_url("/status?n=200")) - response = await self.make_request(request) + async def test_response_has_correct_certificate_ip_address( + self, + server_port: int, + client: H2ClientProtocol, + client_certificate: PrivateCertificate, + ) -> None: + request = Request(self.get_url(server_port, "/status?n=200")) + response = await make_request(client, request) assert response.request == request assert isinstance(response.certificate, Certificate) assert response.certificate.original is not None - assert response.certificate.getIssuer() == self.client_certificate.getIssuer() + assert response.certificate.getIssuer() == client_certificate.getIssuer() assert response.certificate.getPublicKey().matches( - self.client_certificate.getPublicKey() + client_certificate.getPublicKey() ) assert isinstance(response.ip_address, IPv4Address) assert str(response.ip_address) == "127.0.0.1" - async def _check_invalid_netloc(self, url: str) -> None: - from scrapy.core.http2.stream import InvalidHostname + @staticmethod + async def _check_invalid_netloc(client: H2ClientProtocol, url: str) -> None: + from scrapy.core.http2.stream import InvalidHostname # noqa: PLC0415 request = Request(url) with pytest.raises(InvalidHostname) as exc_info: - await self.make_request(request) + await make_request(client, request) error_msg = str(exc_info.value) assert "localhost" in error_msg assert "127.0.0.1" in error_msg assert str(request) in error_msg @deferred_f_from_coro_f - async def test_invalid_hostname(self): - await self._check_invalid_netloc("https://notlocalhost.notlocalhostdomain") + async def test_invalid_hostname(self, client: H2ClientProtocol) -> None: + await self._check_invalid_netloc( + client, "https://notlocalhost.notlocalhostdomain" + ) @deferred_f_from_coro_f - async def test_invalid_host_port(self): - port = self.port_number + 1 - await self._check_invalid_netloc(f"https://127.0.0.1:{port}") + async def test_invalid_host_port( + self, server_port: int, client: H2ClientProtocol + ) -> None: + port = server_port + 1 + await self._check_invalid_netloc(client, f"https://127.0.0.1:{port}") @deferred_f_from_coro_f - async def test_connection_stays_with_invalid_requests(self): - await maybe_deferred_to_future(self.test_invalid_hostname()) - await maybe_deferred_to_future(self.test_invalid_host_port()) - await maybe_deferred_to_future(self.test_GET_small_body()) - await maybe_deferred_to_future(self.test_POST_small_json()) + async def test_connection_stays_with_invalid_requests( + self, server_port: int, client: H2ClientProtocol + ): + await maybe_deferred_to_future(self.test_invalid_hostname(client)) + await maybe_deferred_to_future(self.test_invalid_host_port(server_port, client)) + await maybe_deferred_to_future(self.test_GET_small_body(server_port, client)) + await maybe_deferred_to_future(self.test_POST_small_json(server_port, client)) @inlineCallbacks - def test_connection_timeout(self): - request = Request(self.get_url("/timeout")) + def test_connection_timeout( + self, server_port: int, client: H2ClientProtocol + ) -> Generator[Deferred[Any], Any, None]: + request = Request(self.get_url(server_port, "/timeout")) # Update the timer to 1s to test connection timeout - self.client.setTimeout(1) + client.setTimeout(1) with pytest.raises(ResponseFailed) as exc_info: - yield self.make_request_dfd(request) + yield make_request_dfd(client, request) for err in exc_info.value.reasons: - from scrapy.core.http2.protocol import H2ClientProtocol + from scrapy.core.http2.protocol import H2ClientProtocol # noqa: PLC0415 - if isinstance(err, TimeoutError): + if isinstance(err, TxTimeoutError): assert ( f"Connection was IDLE for more than {H2ClientProtocol.IDLE_TIMEOUT}s" in str(err) @@ -642,18 +742,20 @@ class TestHttps2ClientProtocol(TestCase): pytest.fail("No TimeoutError raised.") @deferred_f_from_coro_f - async def test_request_headers_received(self): + async def test_request_headers_received( + self, server_port: int, client: H2ClientProtocol + ) -> None: request = Request( - self.get_url("/request-headers"), + self.get_url(server_port, "/request-headers"), headers={"header-1": "header value 1", "header-2": "header value 2"}, ) - response = await self.make_request(request) + response = await make_request(client, request) assert response.status == 200 assert response.request == request response_headers = json.loads(str(response.body, "utf-8")) assert isinstance(response_headers, dict) for k, v in request.headers.items(): - k, v = str(k, "utf-8"), str(v[0], "utf-8") - assert k in response_headers - assert v == response_headers[k] + k_decoded, v_decoded = str(k, "utf-8"), str(v[0], "utf-8") + assert k_decoded in response_headers + assert v_decoded == response_headers[k_decoded] diff --git a/tests/test_http_headers.py b/tests/test_http_headers.py index 2fcf9e83c..243aa6afe 100644 --- a/tests/test_http_headers.py +++ b/tests/test_http_headers.py @@ -56,12 +56,12 @@ class TestHeaders: def test_encode_latin1(self): h = Headers({"key": "\xa3"}, encoding="latin1") - key, val = dict(h).popitem() + _, val = dict(h).popitem() assert val[0] == b"\xa3" def test_encode_multiple(self): h = Headers({"key": ["\xa3"]}, encoding="utf-8") - key, val = dict(h).popitem() + _, val = dict(h).popitem() assert val[0] == b"\xc2\xa3" def test_delete_and_contains(self): diff --git a/tests/test_http_request.py b/tests/test_http_request.py index 6bf0b8e3f..22f44bfd2 100644 --- a/tests/test_http_request.py +++ b/tests/test_http_request.py @@ -881,7 +881,7 @@ class TestFormRequest(TestRequest): ) with pytest.raises( ValueError, - match="Multiple elements found .* matching the criteria in clickdata", + match=r"Multiple elements found .* matching the criteria in clickdata", ): self.request_class.from_response(response, clickdata={"type": "submit"}) @@ -1441,7 +1441,7 @@ class TestXmlRpcRequest(TestRequest): ) assert r.method == "POST" assert r.encoding == kwargs.get("encoding", "utf-8") - assert r.dont_filter, True + assert r.dont_filter def test_xmlrpc_dumps(self): self._test_request(params=("value",)) @@ -1466,12 +1466,6 @@ class TestJsonRequest(TestRequest): b"Accept": [b"application/json, text/javascript, */*; q=0.01"], } - def setup_method(self): - warnings.simplefilter("always") - - def teardown_method(self): - warnings.resetwarnings() - def test_data(self): r1 = self.request_class(url="http://www.example.com/") assert r1.body == b"" diff --git a/tests/test_http_response.py b/tests/test_http_response.py index fdef5adea..8acdf25ec 100644 --- a/tests/test_http_response.py +++ b/tests/test_http_response.py @@ -333,7 +333,7 @@ class TestResponseBase: if response is None: response = self._links_response() followed = response.follow_all(follow_obj) - for req, target in zip(followed, target_urls): + for req, target in zip(followed, target_urls, strict=False): assert req.url == target yield req @@ -647,7 +647,7 @@ class TestTextResponse(TestResponseBase): # select elements for sellist in [resp.css("a"), resp.xpath("//a")]: - for sel, url in zip(sellist, urls): + for sel, url in zip(sellist, urls, strict=False): self._assert_followed_url(sel, url, response=resp) # select elements @@ -659,7 +659,7 @@ class TestTextResponse(TestResponseBase): # href attributes should work for sellist in [resp.css("a::attr(href)"), resp.xpath("//a/@href")]: - for sel, url in zip(sellist, urls): + for sel, url in zip(sellist, urls, strict=False): self._assert_followed_url(sel, url, response=resp) # non-a elements are not supported @@ -813,7 +813,7 @@ class TestTextResponse(TestResponseBase): text_body = b"""text""" text_response = self.response_class("http://www.example.com", body=text_body) with pytest.raises( - ValueError, match="(Expecting value|Unexpected '<'): line 1" + ValueError, match=r"(Expecting value|Unexpected '<'): line 1" ): text_response.json() diff --git a/tests/test_item.py b/tests/test_item.py index bf51eb398..94742bfed 100644 --- a/tests/test_item.py +++ b/tests/test_item.py @@ -284,9 +284,9 @@ class TestItemMeta: (first_call, second_call) = new_mock.call_args_list[-2:] - mcs, class_name, bases, attrs = first_call[0] + *_, attrs = first_call[0] assert "__classcell__" not in attrs - mcs, class_name, bases, attrs = second_call[0] + *_, attrs = second_call[0] assert "__classcell__" in attrs diff --git a/tests/test_linkextractors.py b/tests/test_linkextractors.py index 1bff369af..15d358d2a 100644 --- a/tests/test_linkextractors.py +++ b/tests/test_linkextractors.py @@ -152,7 +152,7 @@ class Base: def test_nofollow(self): """Test the extractor's behaviour for links with rel='nofollow'""" - html = b"""Page title<title> + html = b"""<html><head><title>Page title