diff --git a/.bandit.yml b/.bandit.yml index 2aae8a0aa..b7f1817e0 100644 --- a/.bandit.yml +++ b/.bandit.yml @@ -1,21 +1,7 @@ skips: -- B101 -- B113 # https://github.com/PyCQA/bandit/issues/1010 -- B105 -- B301 -- B303 -- B306 -- B307 -- B311 -- B320 -- B321 -- B324 -- B402 # https://github.com/scrapy/scrapy/issues/4180 -- B403 -- B404 -- B406 -- B410 -- B503 -- B603 -- B605 +- B101 # assert_used, needed for mypy +- B321 # ftplib, https://github.com/scrapy/scrapy/issues/4180 +- B402 # import_ftplib, https://github.com/scrapy/scrapy/issues/4180 +- B411 # import_xmlrpclib, https://github.com/PyCQA/bandit/issues/1082 +- B503 # ssl_with_bad_defaults exclude_dirs: ['tests'] diff --git a/.bumpversion.cfg b/.bumpversion.cfg index f76bf783d..599cd0cff 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,7 +1,11 @@ [bumpversion] -current_version = 2.11.0 +current_version = 2.11.2 commit = True tag = True tag_name = {new_version} [bumpversion:file:scrapy/VERSION] + +[bumpversion:file:SECURITY.md] +parse = (?P\d+)\.(?P\d+)\.x +serialize = {major}.{minor}.x diff --git a/.coveragerc b/.coveragerc index ad0ee0f6c..f9ad353d5 100644 --- a/.coveragerc +++ b/.coveragerc @@ -4,3 +4,9 @@ include = scrapy/* omit = tests/* disable_warnings = include-ignored + +[report] +# https://github.com/nedbat/coveragepy/issues/831#issuecomment-517778185 +exclude_lines = + pragma: no cover + if TYPE_CHECKING: diff --git a/.flake8 b/.flake8 index 544d72956..c4814f13a 100644 --- a/.flake8 +++ b/.flake8 @@ -1,8 +1,67 @@ [flake8] max-line-length = 119 -ignore = W503, E203 +extend-select = TC, TC1 +ignore = + # black disagrees with flake8 about these + E203, E501, E701, E704, W503 + # Assigning to `os.environ` doesn't clear the environment. + B003 + # Do not use mutable data structures for argument defaults. + B006 + # Loop control variable not used within the loop body. + B007 + # Do not perform function calls in argument defaults. + B008 + # return/continue/break inside finally blocks cause exceptions to be + # silenced. + B012 + # Star-arg unpacking after a keyword argument is strongly discouraged + B026 + # No explicit stacklevel argument found. + B028 + + # docstring does contain unindexed parameters + P102 + # other string does contain unindexed parameters + P103 + + # Missing docstring in public module + D100 + # Missing docstring in public class + D101 + # Missing docstring in public method + D102 + # Missing docstring in public function + D103 + # Missing docstring in public package + D104 + # Missing docstring in magic method + D105 + # Missing docstring in public nested class + D106 + # Missing docstring in __init__ + D107 + # One-line docstring should fit on one line with quotes + D200 + # No blank lines allowed after function docstring + D202 + # 1 blank line required between summary line and description + D205 + # Multi-line docstring closing quotes should be on a separate line + D209 + # First line should end with a period + D400 + # First line should be in imperative mood; try rephrasing + D401 + # First line should not be the function's "signature" + D402 + # First word of the first line should be properly capitalized + D403 + + # Annotation in typing.cast() should be a string literal + TC006 exclude = docs/conf.py @@ -16,6 +75,7 @@ per-file-ignores = scrapy/linkextractors/__init__.py:E402,F401 scrapy/selector/__init__.py:F401 scrapy/spiders/__init__.py:E402,F401 + tests/CrawlerRunner/change_reactor.py:E402 # Issues pending a review: scrapy/utils/url.py:F403,F405 diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index dbcebfa0a..a9fc3dd68 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -1,7 +1,7 @@ # .git-blame-ignore-revs # adding black formatter to all the code e211ec0aa26ecae0da8ae55d064ea60e1efe4d0d -# re applying black to the code with default line length +# reapplying black to the code with default line length 303f0a70fcf8067adf0a909c2096a5009162383a -# reaplying black again and removing line length on pre-commit black config +# reapplying black again and removing line length on pre-commit black config c5cdd0d30ceb68ccba04af0e71d1b8e6678e2962 \ No newline at end of file diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index d6fc0f6c5..9240a16f4 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -15,10 +15,13 @@ jobs: - python-version: "3.12" env: TOXENV: pylint - - python-version: 3.8 + - python-version: "3.9" env: TOXENV: typing - - python-version: "3.11" # Keep in sync with .readthedocs.yml + - python-version: "3.9" + env: + TOXENV: typing-tests + - python-version: "3.12" # Keep in sync with .readthedocs.yml env: TOXENV: docs - python-version: "3.12" @@ -29,7 +32,7 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} @@ -43,4 +46,4 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: pre-commit/action@v3.0.0 + - uses: pre-commit/action@v3.0.1 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index affaa32a5..5ce48be61 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -13,13 +13,13 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: python-version: 3.12 - run: | pip install --upgrade build twine python -m build - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@v1.6.4 + uses: pypa/gh-action-pypi-publish@v1.10.3 with: password: ${{ secrets.PYPI_TOKEN }} diff --git a/.github/workflows/tests-macos.yml b/.github/workflows/tests-macos.yml index 252176464..27ea0613d 100644 --- a/.github/workflows/tests-macos.yml +++ b/.github/workflows/tests-macos.yml @@ -7,17 +7,17 @@ concurrency: jobs: tests: - runs-on: macos-11 + runs-on: macos-latest strategy: fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: ["3.9", "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/tests-ubuntu.yml b/.github/workflows/tests-ubuntu.yml index f50a4d104..29c870e6a 100644 --- a/.github/workflows/tests-ubuntu.yml +++ b/.github/workflows/tests-ubuntu.yml @@ -12,7 +12,7 @@ jobs: fail-fast: false matrix: include: - - python-version: 3.9 + - python-version: "3.9" env: TOXENV: py - python-version: "3.10" @@ -35,19 +35,19 @@ jobs: TOXENV: pypy3 # pinned deps - - python-version: 3.8.17 + - python-version: 3.9.19 env: TOXENV: pinned - - python-version: 3.8.17 + - python-version: 3.9.19 env: TOXENV: asyncio-pinned - - python-version: pypy3.8 + - python-version: pypy3.9 env: TOXENV: pypy3-pinned - - python-version: 3.8.17 + - python-version: 3.9.19 env: TOXENV: extra-deps-pinned - - python-version: 3.8.17 + - python-version: 3.9.19 env: TOXENV: botocore-pinned @@ -62,7 +62,7 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml index 757d62285..5728c6fd0 100644 --- a/.github/workflows/tests-windows.yml +++ b/.github/workflows/tests-windows.yml @@ -12,12 +12,9 @@ jobs: fail-fast: false matrix: include: - - python-version: 3.8 + - python-version: "3.9" env: TOXENV: windows-pinned - - python-version: 3.9 - env: - TOXENV: py - python-version: "3.10" env: TOXENV: py @@ -35,7 +32,7 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0cff5cc73..fbd710f6f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,24 +1,36 @@ repos: - repo: https://github.com/PyCQA/bandit - rev: 1.7.5 + rev: 1.7.9 hooks: - id: bandit args: [-r, -c, .bandit.yml] - repo: https://github.com/PyCQA/flake8 - rev: 6.1.0 + rev: 7.1.0 hooks: - id: flake8 + additional_dependencies: + - flake8-bugbear + - flake8-comprehensions + - flake8-debugger + - flake8-docstrings + - flake8-string-format + - flake8-type-checking - repo: https://github.com/psf/black.git - rev: 23.9.1 + rev: 24.4.2 hooks: - id: black - repo: https://github.com/pycqa/isort - rev: 5.12.0 + rev: 5.13.2 hooks: - id: isort - repo: https://github.com/adamchainz/blacken-docs - rev: 1.16.0 + rev: 1.18.0 hooks: - id: blacken-docs additional_dependencies: - - black==23.9.1 + - black==24.4.2 +- repo: https://github.com/asottile/pyupgrade + rev: v3.18.0 + hooks: + - id: pyupgrade + args: [--py39-plus] diff --git a/.readthedocs.yml b/.readthedocs.yml index e71d34f3a..0c544df7e 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -9,7 +9,7 @@ build: tools: # For available versions, see: # https://docs.readthedocs.io/en/stable/config-file/v2.html#build-tools-python - python: "3.11" # Keep in sync with .github/workflows/checks.yml + python: "3.12" # Keep in sync with .github/workflows/checks.yml python: install: diff --git a/MANIFEST.in b/MANIFEST.in index 4920dc0c3..06971e39c 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,9 +1,8 @@ -include README.rst -include AUTHORS -include INSTALL -include LICENSE -include MANIFEST.in +include CODE_OF_CONDUCT.md +include CONTRIBUTING.md +include INSTALL.md include NEWS +include SECURITY.md include scrapy/VERSION include scrapy/mime.types @@ -12,16 +11,13 @@ include scrapy/py.typed include codecov.yml include conftest.py include pytest.ini -include requirements-*.txt include tox.ini recursive-include scrapy/templates * -recursive-include scrapy license.txt recursive-include docs * prune docs/build recursive-include extras * -recursive-include bin * recursive-include tests * global-exclude __pycache__ *.py[cod] diff --git a/README.rst b/README.rst index 14adff648..e640bce35 100644 --- a/README.rst +++ b/README.rst @@ -59,7 +59,7 @@ including a list of features. Requirements ============ -* Python 3.8+ +* Python 3.9+ * Works on Linux, Windows, macOS, BSD Install diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..51305d95e --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,12 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +| ------- | ------------------ | +| 2.11.x | :white_check_mark: | +| < 2.11.x | :x: | + +## Reporting a Vulnerability + +Please report the vulnerability using https://github.com/scrapy/scrapy/security/advisories/new. diff --git a/conftest.py b/conftest.py index 2bfa46f5a..2ab3dffd4 100644 --- a/conftest.py +++ b/conftest.py @@ -1,10 +1,6 @@ -import platform -import sys from pathlib import Path import pytest -from twisted import version as twisted_version -from twisted.python.versions import Version from twisted.web.http import H2_ENABLED from scrapy.utils.reactor import install_reactor @@ -85,14 +81,12 @@ def only_not_asyncio(request, reactor_pytest): def requires_uvloop(request): if not request.node.get_closest_marker("requires_uvloop"): return - if sys.implementation.name == "pypy": - pytest.skip("uvloop does not support pypy properly") - if platform.system() == "Windows": - pytest.skip("uvloop does not support Windows") - if twisted_version == Version("twisted", 21, 2, 0): - pytest.skip("https://twistedmatrix.com/trac/ticket/10106") - if sys.version_info >= (3, 12): - pytest.skip("uvloop doesn't support Python 3.12 yet") + try: + import uvloop + + del uvloop + except ImportError: + pytest.skip("uvloop is not installed") def pytest_configure(config): diff --git a/docs/conf.py b/docs/conf.py index 9ca0f817a..dcd2c9a3a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -10,7 +10,6 @@ # serve to show the default. import sys -from datetime import datetime from pathlib import Path # If your extensions are in another directory, add it here. If the directory @@ -48,7 +47,7 @@ master_doc = "index" # General information about the project. project = "Scrapy" -copyright = f"2008–{datetime.now().year}, Scrapy developers" +copyright = "Scrapy developers" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -227,7 +226,7 @@ latex_documents = [ # A list of regular expressions that match URIs that should not be checked when # doing a linkcheck build. linkcheck_ignore = [ - "http://localhost:\d+", + r"http://localhost:\d+", "http://hg.scrapy.org", "http://directory.google.com/", ] diff --git a/docs/faq.rst b/docs/faq.rst index 100e850b0..1eeae23dd 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -138,39 +138,37 @@ See previous question. How can I prevent memory errors due to many allowed domains? ------------------------------------------------------------ -If you have a spider with a long list of -:attr:`~scrapy.Spider.allowed_domains` (e.g. 50,000+), consider -replacing the default -:class:`~scrapy.spidermiddlewares.offsite.OffsiteMiddleware` spider middleware -with a :ref:`custom spider middleware ` that requires -less memory. For example: +If you have a spider with a long list of :attr:`~scrapy.Spider.allowed_domains` +(e.g. 50,000+), consider replacing the default +:class:`~scrapy.downloadermiddlewares.offsite.OffsiteMiddleware` downloader +middleware with a :ref:`custom downloader middleware +` that requires less memory. For example: - If your domain names are similar enough, use your own regular expression - instead joining the strings in - :attr:`~scrapy.Spider.allowed_domains` into a complex regular - expression. + instead joining the strings in :attr:`~scrapy.Spider.allowed_domains` into + a complex regular expression. - If you can `meet the installation requirements`_, use pyre2_ instead of Python’s re_ to compile your URL-filtering regular expression. See :issue:`1908`. -See also other suggestions at `StackOverflow`_. +See also `other suggestions at StackOverflow +`__. .. note:: Remember to disable - :class:`scrapy.spidermiddlewares.offsite.OffsiteMiddleware` when you enable - your custom implementation: + :class:`scrapy.downloadermiddlewares.offsite.OffsiteMiddleware` when you + enable your custom implementation: .. code-block:: python - SPIDER_MIDDLEWARES = { - "scrapy.spidermiddlewares.offsite.OffsiteMiddleware": None, - "myproject.middlewares.CustomOffsiteMiddleware": 500, + DOWNLOADER_MIDDLEWARES = { + "scrapy.downloadermiddlewares.offsite.OffsiteMiddleware": None, + "myproject.middlewares.CustomOffsiteMiddleware": 50, } .. _meet the installation requirements: https://github.com/andreasvc/pyre2#installation .. _pyre2: https://github.com/andreasvc/pyre2 .. _re: https://docs.python.org/library/re.html -.. _StackOverflow: https://stackoverflow.com/q/36440681/939364 Can I use Basic HTTP Authentication in my spiders? -------------------------------------------------- @@ -206,12 +204,10 @@ I get "Filtered offsite request" messages. How can I fix them? Those messages (logged with ``DEBUG`` level) don't necessarily mean there is a problem, so you may not need to fix them. -Those messages are thrown by the Offsite Spider Middleware, which is a spider -middleware (enabled by default) whose purpose is to filter out requests to -domains outside the ones covered by the spider. - -For more info see: -:class:`~scrapy.spidermiddlewares.offsite.OffsiteMiddleware`. +Those messages are thrown by +:class:`~scrapy.downloadermiddlewares.offsite.OffsiteMiddleware`, which is a +downloader middleware (enabled by default) whose purpose is to filter out +requests to domains outside the ones covered by the spider. What is the recommended way to deploy a Scrapy crawler in production? --------------------------------------------------------------------- @@ -273,7 +269,7 @@ To dump into a CSV file:: scrapy crawl myspider -O items.csv -To dump into a XML file:: +To dump into an XML file:: scrapy crawl myspider -O items.xml @@ -297,9 +293,13 @@ build the DOM of the entire feed in memory, and this can be quite slow and consume a lot of memory. In order to avoid parsing all the entire feed at once in memory, you can use -the functions ``xmliter`` and ``csviter`` from ``scrapy.utils.iterators`` -module. In fact, this is what the feed spiders (see :ref:`topics-spiders`) use -under the cover. +the :func:`~scrapy.utils.iterators.xmliter_lxml` and +:func:`~scrapy.utils.iterators.csviter` functions. In fact, this is what +:class:`~scrapy.spiders.XMLFeedSpider` uses. + +.. autofunction:: scrapy.utils.iterators.xmliter_lxml + +.. autofunction:: scrapy.utils.iterators.csviter Does Scrapy manage cookies automatically? ----------------------------------------- @@ -417,8 +417,8 @@ How can I make a blank request? blank_request = Request("data:,") -In this case, the URL is set to a data URI scheme. Data URLs allow you to include data -in-line in web pages as if they were external resources. The "data:" scheme with an empty +In this case, the URL is set to a data URI scheme. Data URLs allow you to include data +inline within web pages, similar to external resources. The "data:" scheme with an empty content (",") essentially creates a request to a data URL without any specific content. diff --git a/docs/intro/install.rst b/docs/intro/install.rst index c90c1d2bf..ef541368a 100644 --- a/docs/intro/install.rst +++ b/docs/intro/install.rst @@ -9,7 +9,7 @@ Installation guide Supported Python versions ========================= -Scrapy requires Python 3.8+, either the CPython implementation (default) or +Scrapy requires Python 3.9+, either the CPython implementation (default) or the PyPy implementation (see :ref:`python:implementations`). .. _intro-install-scrapy: @@ -37,7 +37,7 @@ Note that sometimes this may require solving compilation issues for some Scrapy dependencies depending on your operating system, so be sure to check the :ref:`intro-install-platform-notes`. -For more detailed and platform specifics instructions, as well as +For more detailed and platform-specific instructions, as well as troubleshooting information, read on. @@ -101,7 +101,7 @@ Windows ------- Though it's possible to install Scrapy on Windows using pip, we recommend you -to install `Anaconda`_ or `Miniconda`_ and use the package from the +install `Anaconda`_ or `Miniconda`_ and use the package from the `conda-forge`_ channel, which will avoid most installation issues. Once you've installed `Anaconda`_ or `Miniconda`_, install Scrapy with:: @@ -141,7 +141,7 @@ But it should support older versions of Ubuntu too, like Ubuntu 14.04, albeit with potential issues with TLS connections. **Don't** use the ``python-scrapy`` package provided by Ubuntu, they are -typically too old and slow to catch up with latest Scrapy. +typically too old and slow to catch up with the latest Scrapy release. To install Scrapy on Ubuntu (or Ubuntu-based) systems, you need to install @@ -170,7 +170,7 @@ macOS Building Scrapy's dependencies requires the presence of a C compiler and development headers. On macOS this is typically provided by Apple’s Xcode -development tools. To install the Xcode command line tools open a terminal +development tools. To install the Xcode command-line tools, open a terminal window and run:: xcode-select --install @@ -200,11 +200,6 @@ solutions: brew install python - * Latest versions of python have ``pip`` bundled with them so you won't need - to install it separately. If this is not the case, upgrade python:: - - brew update; brew upgrade python - * *(Optional)* :ref:`Install Scrapy inside a Python virtual environment `. diff --git a/docs/intro/overview.rst b/docs/intro/overview.rst index 542760b4f..cd17b1968 100644 --- a/docs/intro/overview.rst +++ b/docs/intro/overview.rst @@ -44,13 +44,13 @@ https://quotes.toscrape.com, following the pagination: if next_page is not None: yield response.follow(next_page, self.parse) -Put this in a text file, name it to something like ``quotes_spider.py`` +Put this in a text file, name it something like ``quotes_spider.py`` and run the spider using the :command:`runspider` command:: scrapy runspider quotes_spider.py -o quotes.jsonl When this finishes you will have in the ``quotes.jsonl`` file a list of the -quotes in JSON Lines format, containing text and author, looking like this:: +quotes in JSON Lines format, containing the text and author, which will look like this:: {"author": "Jane Austen", "text": "\u201cThe person, be it gentleman or lady, who has not pleasure in a good novel, must be intolerably stupid.\u201d"} {"author": "Steve Martin", "text": "\u201cA day without sunshine is like, you know, night.\u201d"} @@ -65,27 +65,27 @@ When you ran the command ``scrapy runspider quotes_spider.py``, Scrapy looked fo Spider definition inside it and ran it through its crawler engine. The crawl started by making requests to the URLs defined in the ``start_urls`` -attribute (in this case, only the URL for quotes in *humor* category) +attribute (in this case, only the URL for quotes in the *humor* category) and called the default callback method ``parse``, passing the response object as an argument. In the ``parse`` callback, we loop through the quote elements using a CSS Selector, yield a Python dict with the extracted quote text and author, look for a link to the next page and schedule another request using the same ``parse`` method as callback. -Here you notice one of the main advantages about Scrapy: requests are +Here you will notice one of the main advantages of Scrapy: requests are :ref:`scheduled and processed asynchronously `. This means that Scrapy doesn't need to wait for a request to be finished and processed, it can send another request or do other things in the meantime. This -also means that other requests can keep going even if some request fails or an +also means that other requests can keep going even if a request fails or an error happens while handling it. While this enables you to do very fast crawls (sending multiple concurrent requests at the same time, in a fault-tolerant way) Scrapy also gives you control over the politeness of the crawl through :ref:`a few settings `. You can do things like setting a download delay between -each request, limiting amount of concurrent requests per domain or per IP, and +each request, limiting the amount of concurrent requests per domain or per IP, and even :ref:`using an auto-throttling extension ` that tries -to figure out these automatically. +to figure these settings out automatically. .. note:: @@ -106,10 +106,10 @@ scraping easy and efficient, such as: * Built-in support for :ref:`selecting and extracting ` data from HTML/XML sources using extended CSS selectors and XPath expressions, - with helper methods to extract using regular expressions. + with helper methods for extraction using regular expressions. * An :ref:`interactive shell console ` (IPython aware) for trying - out the CSS and XPath expressions to scrape data, very useful when writing or + out the CSS and XPath expressions to scrape data, which is very useful when writing or debugging your spiders. * Built-in support for :ref:`generating feed exports ` in @@ -124,7 +124,7 @@ scraping easy and efficient, such as: well-defined API (middlewares, :ref:`extensions `, and :ref:`pipelines `). -* Wide range of built-in extensions and middlewares for handling: +* A wide range of built-in extensions and middlewares for handling: - cookies and session handling - HTTP features like compression, authentication, caching diff --git a/docs/intro/tutorial.rst b/docs/intro/tutorial.rst index 8ea98f29b..dd1efd3b3 100644 --- a/docs/intro/tutorial.rst +++ b/docs/intro/tutorial.rst @@ -18,11 +18,11 @@ This tutorial will walk you through these tasks: 4. Changing spider to recursively follow links 5. Using spider arguments -Scrapy is written in Python_. If you're new to the language you might want to -start by getting an idea of what the language is like, to get the most out of -Scrapy. +Scrapy is written in Python_. The more you learn about Python, the more you +can get out of Scrapy. -If you're already familiar with other languages, and want to learn Python quickly, the `Python Tutorial`_ is a good resource. +If you're already familiar with other languages and want to learn Python quickly, the +`Python Tutorial`_ is a good resource. If you're new to programming and want to start with Python, the following books may be useful to you: @@ -76,10 +76,9 @@ This will create a ``tutorial`` directory with the following contents:: Our first Spider ================ -Spiders are classes that you define and that Scrapy uses to scrape information -from a website (or a group of websites). They must subclass -:class:`~scrapy.Spider` and define the initial requests to make, -optionally how to follow links in the pages, and how to parse the downloaded +Spiders are classes that you define and that Scrapy uses to scrape information from a website +(or a group of websites). They must subclass :class:`~scrapy.Spider` and define the initial +requests to be made, and optionally, how to follow links in pages and parse the downloaded page content to extract data. This is the code for our first Spider. Save it in a file named @@ -138,7 +137,7 @@ To put our spider to work, go to the project's top level directory and run:: scrapy crawl quotes -This command runs the spider with name ``quotes`` that we've just added, that +This command runs the spider named ``quotes`` that we've just added, that will send some requests for the ``quotes.toscrape.com`` domain. You will get an output similar to this:: @@ -169,7 +168,7 @@ Scrapy schedules the :class:`scrapy.Request ` objects returned by the ``start_requests`` method of the Spider. Upon receiving a response for each one, it instantiates :class:`~scrapy.http.Response` objects and calls the callback method associated with the request (in this case, the -``parse`` method) passing the response as argument. +``parse`` method) passing the response as an argument. A shortcut to the start_requests method @@ -217,8 +216,8 @@ using the :ref:`Scrapy shell `. Run:: .. note:: - Remember to always enclose urls in quotes when running Scrapy shell from - command-line, otherwise urls containing arguments (i.e. ``&`` character) + Remember to always enclose URLs in quotes when running Scrapy shell from the + command line, otherwise URLs containing arguments (i.e. ``&`` character) will not work. On Windows, use double quotes instead:: @@ -257,7 +256,7 @@ object: The result of running ``response.css('title')`` is a list-like object called :class:`~scrapy.selector.SelectorList`, which represents a list of :class:`~scrapy.Selector` objects that wrap around XML/HTML elements -and allow you to run further queries to fine-grain the selection or extract the +and allow you to run further queries to refine the selection or extract the data. To extract the text from the title above, you can do: @@ -354,12 +353,12 @@ Besides `CSS`_, Scrapy selectors also support using `XPath`_ expressions: XPath expressions are very powerful, and are the foundation of Scrapy Selectors. In fact, CSS selectors are converted to XPath under-the-hood. You -can see that if you read closely the text representation of the selector -objects in the shell. +can see that if you read the text representation of the selector +objects in the shell closely. While perhaps not as popular as CSS selectors, XPath expressions offer more power because besides navigating the structure, it can also look at the -content. Using XPath, you're able to select things like: *select the link +content. Using XPath, you're able to select things like: *the link that contains the text "Next Page"*. This makes XPath very fitting to the task of scraping, and we encourage you to learn XPath even if you already know how to construct CSS selectors, it will make scraping much easier. @@ -422,7 +421,7 @@ variable, so that we can run our CSS selectors directly on a particular quote: >>> quote = response.css("div.quote")[0] -Now, let's extract ``text``, ``author`` and the ``tags`` from that quote +Now, let's extract the ``text``, ``author`` and ``tags`` from that quote using the ``quote`` object we just created: .. code-block:: pycon @@ -448,7 +447,7 @@ to get all of them: from sys import version_info Having figured out how to extract each bit, we can now iterate over all the -quotes elements and put them together into a Python dictionary: +quote elements and put them together into a Python dictionary: .. code-block:: pycon @@ -465,8 +464,8 @@ quotes elements and put them together into a Python dictionary: Extracting data in our spider ----------------------------- -Let's get back to our spider. Until now, it doesn't extract any data in -particular, just saves the whole HTML page to a local file. Let's integrate the +Let's get back to our spider. Until now, it hasn't extracted any data in +particular, just saving the whole HTML page to a local file. Let's integrate the extraction logic above into our spider. A Scrapy spider typically generates many dictionaries containing the data @@ -529,8 +528,8 @@ using a different serialization format, such as `JSON Lines`_:: scrapy crawl quotes -o quotes.jsonl -The `JSON Lines`_ format is useful because it's stream-like, you can easily -append new records to it. It doesn't have the same problem of JSON when you run +The `JSON Lines`_ format is useful because it's stream-like, so you can easily +append new records to it. It doesn't have the same problem as JSON when you run twice. Also, as each record is a separate line, you can process big files without having to fit everything in memory, there are tools like `JQ`_ to help do that at the command-line. @@ -555,7 +554,7 @@ from https://quotes.toscrape.com, you want quotes from all the pages in the webs Now that you know how to extract data from pages, let's see how to follow links from them. -First thing is to extract the link to the page we want to follow. Examining +The first thing to do is extract the link to the page we want to follow. Examining our page, we can see there is a link to the next page with the following markup: @@ -589,7 +588,7 @@ There is also an ``attrib`` property available >>> response.css("li.next a").attrib["href"] '/page/2/' -Let's see now our spider modified to recursively follow the link to the next +Now let's see our spider, modified to recursively follow the link to the next page, extracting data from it: .. code-block:: python @@ -756,8 +755,8 @@ Another interesting thing this spider demonstrates is that, even if there are many quotes from the same author, we don't need to worry about visiting the same author page multiple times. By default, Scrapy filters out duplicated requests to URLs already visited, avoiding the problem of hitting servers too -much because of a programming mistake. This can be configured by the setting -:setting:`DUPEFILTER_CLASS`. +much because of a programming mistake. This can be configured in the +:setting:`DUPEFILTER_CLASS` setting. Hopefully by now you have a good understanding of how to use the mechanism of following links and callbacks with Scrapy. @@ -824,12 +823,12 @@ Next steps ========== This tutorial covered only the basics of Scrapy, but there's a lot of other -features not mentioned here. Check the :ref:`topics-whatelse` section in +features not mentioned here. Check the :ref:`topics-whatelse` section in the :ref:`intro-overview` chapter for a quick overview of the most important ones. You can continue from the section :ref:`section-basics` to know more about the command-line tool, spiders, selectors and other things the tutorial hasn't covered like -modeling the scraped data. If you prefer to play with an example project, check +modeling the scraped data. If you'd prefer to play with an example project, check the :ref:`intro-examples` section. .. _JSON: https://en.wikipedia.org/wiki/JSON diff --git a/docs/news.rst b/docs/news.rst index 65d9c5181..2bbca77cc 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -3,6 +3,235 @@ Release notes ============= +.. _release-VERSION: + +Scrapy VERSION (YYYY-MM-DD) +--------------------------- + +New features +~~~~~~~~~~~~ + +- If :setting:`SPIDER_LOADER_WARN_ONLY` is set to ``True``, + ``SpiderLoader`` does not raise :exc:`SyntaxError` but emits a warning instead. + +Deprecations +~~~~~~~~~~~~ + +- :meth:`scrapy.core.downloader.Downloader._get_slot_key` is deprecated, use + :meth:`scrapy.core.downloader.Downloader.get_slot_key` instead. + (:issue:`6340`) + + +.. _release-2.11.2: + +Scrapy 2.11.2 (2024-05-14) +-------------------------- + +Security bug fixes +~~~~~~~~~~~~~~~~~~ + +- Redirects to non-HTTP protocols are no longer followed. Please, see the + `23j4-mw76-5v7h security advisory`_ for more information. (:issue:`457`) + + .. _23j4-mw76-5v7h security advisory: https://github.com/scrapy/scrapy/security/advisories/GHSA-23j4-mw76-5v7h + +- The ``Authorization`` header is now dropped on redirects to a different + scheme (``http://`` or ``https://``) or port, even if the domain is the + same. Please, see the `4qqq-9vqf-3h3f security advisory`_ for more + information. + + .. _4qqq-9vqf-3h3f security advisory: https://github.com/scrapy/scrapy/security/advisories/GHSA-4qqq-9vqf-3h3f + +- When using system proxy settings that are different for ``http://`` and + ``https://``, redirects to a different URL scheme will now also trigger the + corresponding change in proxy settings for the redirected request. Please, + see the `jm3v-qxmh-hxwv security advisory`_ for more information. + (:issue:`767`) + + .. _jm3v-qxmh-hxwv security advisory: https://github.com/scrapy/scrapy/security/advisories/GHSA-jm3v-qxmh-hxwv + +- :attr:`Spider.allowed_domains ` is now + enforced for all requests, and not only requests from spider callbacks. + (:issue:`1042`, :issue:`2241`, :issue:`6358`) + +- :func:`~scrapy.utils.iterators.xmliter_lxml` no longer resolves XML + entities. (:issue:`6265`) + +- defusedxml_ is now used to make + :class:`scrapy.http.request.rpc.XmlRpcRequest` more secure. + (:issue:`6250`, :issue:`6251`) + + .. _defusedxml: https://github.com/tiran/defusedxml + +Bug fixes +~~~~~~~~~ + +- Restored support for brotlipy_, which had been dropped in Scrapy 2.11.1 in + favor of brotli_. (:issue:`6261`) + + .. _brotli: https://github.com/google/brotli + + .. note:: brotlipy is deprecated, both in Scrapy and upstream. Use brotli + instead if you can. + +- Make :setting:`METAREFRESH_IGNORE_TAGS` ``["noscript"]`` by default. This + prevents + :class:`~scrapy.downloadermiddlewares.redirect.MetaRefreshMiddleware` from + following redirects that would not be followed by web browsers with + JavaScript enabled. (:issue:`6342`, :issue:`6347`) + +- During :ref:`feed export `, do not close the + underlying file from :ref:`built-in post-processing plugins + `. + (:issue:`5932`, :issue:`6178`, :issue:`6239`) + +- :class:`LinkExtractor ` + now properly applies the ``unique`` and ``canonicalize`` parameters. + (:issue:`3273`, :issue:`6221`) + +- Do not initialize the scheduler disk queue if :setting:`JOBDIR` is an empty + string. (:issue:`6121`, :issue:`6124`) + +- Fix :attr:`Spider.logger ` not logging custom extra + information. (:issue:`6323`, :issue:`6324`) + +- ``robots.txt`` files with a non-UTF-8 encoding no longer prevent parsing + the UTF-8-compatible (e.g. ASCII) parts of the document. + (:issue:`6292`, :issue:`6298`) + +- :meth:`scrapy.http.cookies.WrappedRequest.get_header` no longer raises an + exception if ``default`` is ``None``. + (:issue:`6308`, :issue:`6310`) + +- :class:`~scrapy.selector.Selector` now uses + :func:`scrapy.utils.response.get_base_url` to determine the base URL of a + given :class:`~scrapy.http.Response`. (:issue:`6265`) + +- The :meth:`media_to_download` method of :ref:`media pipelines + ` now logs exceptions before stripping them. + (:issue:`5067`, :issue:`5068`) + +- When passing a callback to the :command:`parse` command, build the callback + callable with the right signature. + (:issue:`6182`) + +Documentation +~~~~~~~~~~~~~ + +- Add a FAQ entry about :ref:`creating blank requests `. + (:issue:`6203`, :issue:`6208`) + +- Document that :attr:`scrapy.selector.Selector.type` can be ``"json"``. + (:issue:`6328`, :issue:`6334`) + +Quality assurance +~~~~~~~~~~~~~~~~~ + +- Make builds reproducible. (:issue:`5019`, :issue:`6322`) + +- Packaging and test fixes. + (:issue:`6286`, :issue:`6290`, :issue:`6312`, :issue:`6316`, :issue:`6344`) + + +.. _release-2.11.1: + +Scrapy 2.11.1 (2024-02-14) +-------------------------- + +Highlights: + +- Security bug fixes. + +- Support for Twisted >= 23.8.0. + +- Documentation improvements. + +Security bug fixes +~~~~~~~~~~~~~~~~~~ + +- Addressed `ReDoS vulnerabilities`_: + + - ``scrapy.utils.iterators.xmliter`` is now deprecated in favor of + :func:`~scrapy.utils.iterators.xmliter_lxml`, which + :class:`~scrapy.spiders.XMLFeedSpider` now uses. + + To minimize the impact of this change on existing code, + :func:`~scrapy.utils.iterators.xmliter_lxml` now supports indicating + the node namespace with a prefix in the node name, and big files with + highly nested trees when using libxml2 2.7+. + + - Fixed regular expressions in the implementation of the + :func:`~scrapy.utils.response.open_in_browser` function. + + Please, see the `cc65-xxvf-f7r9 security advisory`_ for more information. + + .. _ReDoS vulnerabilities: https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS + .. _cc65-xxvf-f7r9 security advisory: https://github.com/scrapy/scrapy/security/advisories/GHSA-cc65-xxvf-f7r9 + +- :setting:`DOWNLOAD_MAXSIZE` and :setting:`DOWNLOAD_WARNSIZE` now also apply + to the decompressed response body. Please, see the `7j7m-v7m3-jqm7 security + advisory`_ for more information. + + .. _7j7m-v7m3-jqm7 security advisory: https://github.com/scrapy/scrapy/security/advisories/GHSA-7j7m-v7m3-jqm7 + +- Also in relation with the `7j7m-v7m3-jqm7 security advisory`_, the + deprecated ``scrapy.downloadermiddlewares.decompression`` module has been + removed. + +- The ``Authorization`` header is now dropped on redirects to a different + domain. Please, see the `cw9j-q3vf-hrrv security advisory`_ for more + information. + + .. _cw9j-q3vf-hrrv security advisory: https://github.com/scrapy/scrapy/security/advisories/GHSA-cw9j-q3vf-hrrv + +Modified requirements +~~~~~~~~~~~~~~~~~~~~~ + +- The Twisted dependency is no longer restricted to < 23.8.0. (:issue:`6024`, + :issue:`6064`, :issue:`6142`) + +Bug fixes +~~~~~~~~~ + +- The OS signal handling code was refactored to no longer use private Twisted + functions. (:issue:`6024`, :issue:`6064`, :issue:`6112`) + +Documentation +~~~~~~~~~~~~~ + +- Improved documentation for :class:`~scrapy.crawler.Crawler` initialization + changes made in the 2.11.0 release. (:issue:`6057`, :issue:`6147`) + +- Extended documentation for :attr:`Request.meta `. + (:issue:`5565`) + +- Fixed the :reqmeta:`dont_merge_cookies` documentation. (:issue:`5936`, + :issue:`6077`) + +- Added a link to Zyte's export guides to the :ref:`feed exports + ` documentation. (:issue:`6183`) + +- Added a missing note about backward-incompatible changes in + :class:`~scrapy.exporters.PythonItemExporter` to the 2.11.0 release notes. + (:issue:`6060`, :issue:`6081`) + +- Added a missing note about removing the deprecated + ``scrapy.utils.boto.is_botocore()`` function to the 2.8.0 release notes. + (:issue:`6056`, :issue:`6061`) + +- Other documentation improvements. (:issue:`6128`, :issue:`6144`, + :issue:`6163`, :issue:`6190`, :issue:`6192`) + +Quality assurance +~~~~~~~~~~~~~~~~~ + +- Added Python 3.12 to the CI configuration, re-enabled tests that were + disabled when the pre-release support was added. (:issue:`5985`, + :issue:`6083`, :issue:`6098`) + +- Fixed a test issue on PyPy 7.3.14. (:issue:`6204`, :issue:`6205`) + + .. _release-2.11.0: Scrapy 2.11.0 (2023-09-18) @@ -62,6 +291,9 @@ Deprecation removals 1.0.0, use :attr:`CrawlerRunner.spider_loader ` instead. (:issue:`6010`) +- The :func:`scrapy.utils.response.response_httprepr` function, deprecated in + Scrapy 2.6.0, has now been removed. (:issue:`6111`) + Deprecations ~~~~~~~~~~~~ @@ -1157,6 +1389,9 @@ Deprecations Instead, call :meth:`~scrapy.core.engine.ExecutionEngine.open_spider` first to set the :class:`~scrapy.Spider` object. +- :func:`scrapy.utils.response.response_httprepr` is now deprecated. + (:issue:`4972`) + New features ~~~~~~~~~~~~ @@ -1295,7 +1530,7 @@ Documentation - Provided better context and instructions to disable the :setting:`URLLENGTH_LIMIT` setting. (:issue:`5135`, :issue:`5250`) -- Documented that :ref:`reppy-parser` does not support Python 3.9+. +- Documented that Reppy parser does not support Python 3.9+. (:issue:`5226`, :issue:`5231`) - Documented :ref:`the scheduler component `. @@ -2871,6 +3106,38 @@ affect subclasses: (:issue:`3884`) +.. _release-1.8.4: + +Scrapy 1.8.4 (2024-02-14) +------------------------- + +**Security bug fixes:** + +- Due to its `ReDoS vulnerabilities`_, ``scrapy.utils.iterators.xmliter`` is + now deprecated in favor of :func:`~scrapy.utils.iterators.xmliter_lxml`, + which :class:`~scrapy.spiders.XMLFeedSpider` now uses. + + To minimize the impact of this change on existing code, + :func:`~scrapy.utils.iterators.xmliter_lxml` now supports indicating + the node namespace as a prefix in the node name, and big files with highly + nested trees when using libxml2 2.7+. + + Please, see the `cc65-xxvf-f7r9 security advisory`_ for more information. + +- :setting:`DOWNLOAD_MAXSIZE` and :setting:`DOWNLOAD_WARNSIZE` now also apply + to the decompressed response body. Please, see the `7j7m-v7m3-jqm7 security + advisory`_ for more information. + +- Also in relation with the `7j7m-v7m3-jqm7 security advisory`_, use of the + ``scrapy.downloadermiddlewares.decompression`` module is discouraged and + will trigger a warning. + +- The ``Authorization`` header is now dropped on redirects to a different + domain. Please, see the `cw9j-q3vf-hrrv security advisory`_ for more + information. + + .. _cw9j-q3vf-hrrv security advisory: https://github.com/scrapy/scrapy/security/advisories/GHSA-cw9j-q3vf-hrrv + .. _release-1.8.3: @@ -3077,7 +3344,7 @@ New features * A new :setting:`ROBOTSTXT_PARSER` setting allows choosing which robots.txt_ parser to use. It includes built-in support for :ref:`RobotFileParser `, - :ref:`Protego ` (default), :ref:`Reppy `, and + :ref:`Protego ` (default), Reppy, and :ref:`Robotexclusionrulesparser `, and allows you to :ref:`implement support for additional parsers ` (:issue:`754`, :issue:`2669`, diff --git a/docs/topics/addons.rst b/docs/topics/addons.rst index 1bf2172bd..d2fc41003 100644 --- a/docs/topics/addons.rst +++ b/docs/topics/addons.rst @@ -150,8 +150,7 @@ Access the crawler instance: def from_crawler(cls, crawler): return cls(crawler) - def update_settings(self, settings): - ... + def update_settings(self, settings): ... Use a fallback component: diff --git a/docs/topics/autothrottle.rst b/docs/topics/autothrottle.rst index 8e6aae65c..8a13b8976 100644 --- a/docs/topics/autothrottle.rst +++ b/docs/topics/autothrottle.rst @@ -47,6 +47,18 @@ effect, but there are some important differences: AutoThrottle doesn't have these issues. +Disabling throttling on a downloader slot +========================================= + +It is possible to disable AutoThrottle for a specific download slot at run time +by setting its ``throttle`` attribute to ``False``, e.g. using +:setting:`DOWNLOAD_SLOTS`. + +Note, however, that AutoThrottle still determines the starting delay of every +slot by setting the ``download_delay`` attribute on the running spider. You +might want to set a custom value for the ``delay`` attribute of the slot, e.g. +using :setting:`DOWNLOAD_SLOTS`. + Throttling algorithm ==================== @@ -131,7 +143,7 @@ AUTOTHROTTLE_TARGET_CONCURRENCY Default: ``1.0`` Average number of requests Scrapy should be sending in parallel to remote -websites. +websites. It must be higher than ``0.0``. By default, AutoThrottle adjusts the delay to send a single concurrent request to each of the remote websites. Set this option to diff --git a/docs/topics/benchmarking.rst b/docs/topics/benchmarking.rst index 0643df6a6..b704e54ed 100644 --- a/docs/topics/benchmarking.rst +++ b/docs/topics/benchmarking.rst @@ -24,7 +24,8 @@ You should see an output like this:: 'scrapy.extensions.telnet.TelnetConsole', 'scrapy.extensions.corestats.CoreStats'] 2016-12-16 21:18:49 [scrapy.middleware] INFO: Enabled downloader middlewares: - ['scrapy.downloadermiddlewares.robotstxt.RobotsTxtMiddleware', + ['scrapy.downloadermiddlewares.offsite.OffsiteMiddleware', + 'scrapy.downloadermiddlewares.robotstxt.RobotsTxtMiddleware', 'scrapy.downloadermiddlewares.httpauth.HttpAuthMiddleware', 'scrapy.downloadermiddlewares.downloadtimeout.DownloadTimeoutMiddleware', 'scrapy.downloadermiddlewares.defaultheaders.DefaultHeadersMiddleware', @@ -37,7 +38,6 @@ You should see an output like this:: 'scrapy.downloadermiddlewares.stats.DownloaderStats'] 2016-12-16 21:18:49 [scrapy.middleware] INFO: Enabled spider middlewares: ['scrapy.spidermiddlewares.httperror.HttpErrorMiddleware', - 'scrapy.spidermiddlewares.offsite.OffsiteMiddleware', 'scrapy.spidermiddlewares.referer.RefererMiddleware', 'scrapy.spidermiddlewares.urllength.UrlLengthMiddleware', 'scrapy.spidermiddlewares.depth.DepthMiddleware'] diff --git a/docs/topics/broad-crawls.rst b/docs/topics/broad-crawls.rst index 8be89feb2..750aae554 100644 --- a/docs/topics/broad-crawls.rst +++ b/docs/topics/broad-crawls.rst @@ -116,7 +116,7 @@ Reduce log level When doing broad crawls you are often only interested in the crawl rates you get and any errors found. These stats are reported by Scrapy when using the ``INFO`` log level. In order to save CPU (and log storage requirements) you -should not use ``DEBUG`` log level when preforming large broad crawls in +should not use ``DEBUG`` log level when performing large broad crawls in production. Using ``DEBUG`` level when developing your (broad) crawler may be fine though. diff --git a/docs/topics/commands.rst b/docs/topics/commands.rst index 1d37895c2..6eb4af9bd 100644 --- a/docs/topics/commands.rst +++ b/docs/topics/commands.rst @@ -6,7 +6,7 @@ Command line tool ================= -Scrapy is controlled through the ``scrapy`` command-line tool, to be referred +Scrapy is controlled through the ``scrapy`` command-line tool, to be referred to here as the "Scrapy tool" to differentiate it from the sub-commands, which we just call "commands" or "Scrapy commands". @@ -185,8 +185,8 @@ And you can see all available commands with:: There are two kinds of commands, those that only work from inside a Scrapy project (Project-specific commands) and those that also work without an active -Scrapy project (Global commands), though they may behave slightly different -when running from inside a project (as they would use the project overridden +Scrapy project (Global commands), though they may behave slightly differently +when run from inside a project (as they would use the project overridden settings). Global commands: @@ -236,7 +236,7 @@ genspider .. versionadded:: 2.6.0 The ability to pass a URL instead of a domain. -Create a new spider in the current folder or in the current project's ``spiders`` folder, if called from inside a project. The ```` parameter is set as the spider's ``name``, while ```` is used to generate the ``allowed_domains`` and ``start_urls`` spider's attributes. +Creates a new spider in the current folder or in the current project's ``spiders`` folder, if called from inside a project. The ```` parameter is set as the spider's ``name``, while ```` is used to generate the ``allowed_domains`` and ``start_urls`` spider's attributes. Usage example:: @@ -253,7 +253,7 @@ Usage example:: $ scrapy genspider -t crawl scrapyorg scrapy.org Created spider 'scrapyorg' using template 'crawl' -This is just a convenience shortcut command for creating spiders based on +This is just a convenient shortcut command for creating spiders based on pre-defined templates, but certainly not the only way to create spiders. You can just create the spider source code files yourself, instead of using this command. @@ -274,9 +274,9 @@ Supported options: * ``-a NAME=VALUE``: set a spider argument (may be repeated) -* ``--output FILE`` or ``-o FILE``: append scraped items to the end of FILE (use - for stdout), to define format set a colon at the end of the output URI (i.e. ``-o FILE:FORMAT``) +* ``--output FILE`` or ``-o FILE``: append scraped items to the end of FILE (use - for stdout). To define the output format, set a colon at the end of the output URI (i.e. ``-o FILE:FORMAT``) -* ``--overwrite-output FILE`` or ``-O FILE``: dump scraped items into FILE, overwriting any existing file, to define format set a colon at the end of the output URI (i.e. ``-O FILE:FORMAT``) +* ``--overwrite-output FILE`` or ``-O FILE``: dump scraped items into FILE, overwriting any existing file. To define the output format, set a colon at the end of the output URI (i.e. ``-O FILE:FORMAT``) * ``--output-format FORMAT`` or ``-t FORMAT``: deprecated way to define format to use for dumping items, does not work in combination with ``-O`` @@ -353,7 +353,7 @@ edit Edit the given spider using the editor defined in the ``EDITOR`` environment variable or (if unset) the :setting:`EDITOR` setting. -This command is provided only as a convenience shortcut for the most common +This command is provided only as a convenient shortcut for the most common case, the developer is of course free to choose any tool or IDE to write and debug spiders. @@ -372,7 +372,7 @@ fetch Downloads the given URL using the Scrapy downloader and writes the contents to standard output. -The interesting thing about this command is that it fetches the page how the +The interesting thing about this command is that it fetches the page the way the spider would download it. For example, if the spider has a ``USER_AGENT`` attribute which overrides the User Agent, it will use that one. diff --git a/docs/topics/contracts.rst b/docs/topics/contracts.rst index 2d61026e9..61aef4bbb 100644 --- a/docs/topics/contracts.rst +++ b/docs/topics/contracts.rst @@ -20,13 +20,13 @@ following example: This function parses a sample response. Some contracts are mingled with this docstring. - @url http://www.amazon.com/s?field-keywords=selfish+gene + @url http://www.example.com/s?field-keywords=selfish+gene @returns items 1 16 @returns requests 0 0 @scrapes Title Author Year Price """ -This callback is tested using three built-in contracts: +You can use the following contracts: .. module:: scrapy.contracts.default @@ -46,6 +46,14 @@ This callback is tested using three built-in contracts: @cb_kwargs {"arg1": "value1", "arg2": "value2", ...} +.. class:: MetadataContract + + This contract (``@meta``) sets the :attr:`meta ` + attribute for the sample request. It must be a valid JSON dictionary. + :: + + @meta {"arg1": "value1", "arg2": "value2", ...} + .. class:: ReturnsContract This contract (``@returns``) sets lower and upper bounds for the items and diff --git a/docs/topics/debug.rst b/docs/topics/debug.rst index 49c5b0410..988e37bbd 100644 --- a/docs/topics/debug.rst +++ b/docs/topics/debug.rst @@ -125,25 +125,15 @@ Fortunately, the :command:`shell` is your bread and butter in this case (see See also: :ref:`topics-shell-inspect-response`. + Open in browser =============== Sometimes you just want to see how a certain response looks in a browser, you -can use the ``open_in_browser`` function for that. Here is an example of how -you would use it: +can use the :func:`~scrapy.utils.response.open_in_browser` function for that: -.. code-block:: python +.. autofunction:: scrapy.utils.response.open_in_browser - from scrapy.utils.response import open_in_browser - - - def parse_details(self, response): - if "item name" not in response.body: - open_in_browser(response) - -``open_in_browser`` will open a browser with the response received by Scrapy at -that point, adjusting the `base tag`_ so that images and styles are displayed -properly. Logging ======= @@ -163,8 +153,6 @@ available in all future runs should they be necessary again: For more information, check the :ref:`topics-logging` section. -.. _base tag: https://www.w3schools.com/tags/tag_base.asp - .. _debug-vscode: Visual Studio Code diff --git a/docs/topics/downloader-middleware.rst b/docs/topics/downloader-middleware.rst index 4940f914a..b62c59ebf 100644 --- a/docs/topics/downloader-middleware.rst +++ b/docs/topics/downloader-middleware.rst @@ -809,6 +809,44 @@ HttpProxyMiddleware Keep in mind this value will take precedence over ``http_proxy``/``https_proxy`` environment variables, and it will also ignore ``no_proxy`` environment variable. +OffsiteMiddleware +----------------- + +.. module:: scrapy.downloadermiddlewares.offsite + :synopsis: Offsite Middleware + +.. class:: OffsiteMiddleware + + .. versionadded:: 2.11.2 + + Filters out Requests for URLs outside the domains covered by the spider. + + This middleware filters out every request whose host names aren't in the + spider's :attr:`~scrapy.Spider.allowed_domains` attribute. + All subdomains of any domain in the list are also allowed. + E.g. the rule ``www.example.org`` will also allow ``bob.www.example.org`` + but not ``www2.example.com`` nor ``example.com``. + + When your spider returns a request for a domain not belonging to those + covered by the spider, this middleware will log a debug message similar to + this one:: + + DEBUG: Filtered offsite request to 'offsite.example': + + To avoid filling the log with too much noise, it will only print one of + these messages for each new domain filtered. So, for example, if another + request for ``offsite.example`` is filtered, no log message will be + printed. But if a request for ``other.example`` is filtered, a message + will be printed (but only for the first request filtered). + + If the spider doesn't define an + :attr:`~scrapy.Spider.allowed_domains` attribute, or the + attribute is empty, the offsite middleware will allow all requests. + + If the request has the :attr:`~scrapy.Request.dont_filter` attribute + set, the offsite middleware will allow the request even if its domain is not + listed in allowed domains. + RedirectMiddleware ------------------ @@ -928,7 +966,15 @@ Meta tags within these tags are ignored. .. versionchanged:: 2.0 The default value of :setting:`METAREFRESH_IGNORE_TAGS` changed from - ``['script', 'noscript']`` to ``[]``. + ``["script", "noscript"]`` to ``[]``. + +.. versionchanged:: 2.11.2 + The default value of :setting:`METAREFRESH_IGNORE_TAGS` changed from + ``[]`` to ``["noscript"]``. + +.. versionchanged:: VERSION + The default value of :setting:`METAREFRESH_IGNORE_TAGS` changed from + ``[]`` to ``['noscript']``. .. setting:: METAREFRESH_MAXDELAY @@ -1086,7 +1132,6 @@ RobotsTxtMiddleware * :ref:`Protego ` (default) * :ref:`RobotFileParser ` * :ref:`Robotexclusionrulesparser ` - * :ref:`Reppy ` (deprecated) You can change the robots.txt_ parser with the :setting:`ROBOTSTXT_PARSER` setting. Or you can also :ref:`implement support for a new parser `. @@ -1154,37 +1199,6 @@ In order to use this parser, set: * :setting:`ROBOTSTXT_PARSER` to ``scrapy.robotstxt.PythonRobotParser`` -.. _reppy-parser: - -Reppy parser -~~~~~~~~~~~~ - -Based on `Reppy `_: - -* is a Python wrapper around `Robots Exclusion Protocol Parser for C++ - `_ - -* is compliant with `Martijn Koster's 1996 draft specification - `_ - -* supports wildcard matching - -* uses the length based rule - -Native implementation, provides better speed than Protego. - -In order to use this parser: - -* Install `Reppy `_ by running ``pip install reppy`` - - .. warning:: `Upstream issue #122 - `_ prevents reppy usage in Python 3.9+. - Because of this the Reppy parser is deprecated. - -* Set :setting:`ROBOTSTXT_PARSER` setting to - ``scrapy.robotstxt.ReppyRobotParser`` - - .. _rerp-parser: Robotexclusionrulesparser diff --git a/docs/topics/dynamic-content.rst b/docs/topics/dynamic-content.rst index a0f4b4411..a99f1e222 100644 --- a/docs/topics/dynamic-content.rst +++ b/docs/topics/dynamic-content.rst @@ -115,15 +115,14 @@ Handling different response formats Once you have a response with the desired data, how you extract the desired data from it depends on the type of response: -- If the response is HTML or XML, use :ref:`selectors +- If the response is HTML, XML or JSON, use :ref:`selectors ` as usual. -- If the response is JSON, use :func:`json.loads` to load the desired data from - :attr:`response.text `: +- If the response is JSON, use :func:`response.json()` to load the desired data: .. code-block:: python - data = json.loads(response.text) + data = response.json() If the desired data is inside HTML or XML code embedded within JSON data, you can load that HTML or XML code into a diff --git a/docs/topics/extensions.rst b/docs/topics/extensions.rst index f7b2f3799..7b34a19d5 100644 --- a/docs/topics/extensions.rst +++ b/docs/topics/extensions.rst @@ -317,6 +317,19 @@ crawls more than that, the spider will be closed with the reason ``closespider_pagecount``. If zero (or non set), spiders won't be closed by number of crawled responses. +.. setting:: CLOSESPIDER_PAGECOUNT_NO_ITEM + +CLOSESPIDER_PAGECOUNT_NO_ITEM +""""""""""""""""""""""""""""" + +Default: ``0`` + +An integer which specifies the maximum number of consecutive responses to crawl +without items scraped. If the spider crawls more consecutive responses than that +and no items are scraped in the meantime, the spider will be closed with the +reason ``closespider_pagecount_no_item``. If zero (or not set), spiders won't be +closed by number of crawled responses with no items. + .. setting:: CLOSESPIDER_ERRORCOUNT CLOSESPIDER_ERRORCOUNT diff --git a/docs/topics/feed-exports.rst b/docs/topics/feed-exports.rst index f64bbac06..922b765db 100644 --- a/docs/topics/feed-exports.rst +++ b/docs/topics/feed-exports.rst @@ -390,7 +390,13 @@ Each plugin is a class that must implement the following methods: .. method:: close(self) - Close the target file object. + Clean up the plugin. + + For example, you might want to close a file wrapper that you might have + used to compress data written into the file received in the ``__init__`` + method. + + .. warning:: Do not close the file from the ``__init__`` method. To pass a parameter to your plugin, use :ref:`feed options `. You can then access those parameters from the ``__init__`` method of your plugin. diff --git a/docs/topics/item-pipeline.rst b/docs/topics/item-pipeline.rst index a5f6e07b8..58c922e0d 100644 --- a/docs/topics/item-pipeline.rst +++ b/docs/topics/item-pipeline.rst @@ -99,7 +99,7 @@ contain a price: adapter["price"] = adapter["price"] * self.vat_factor return item else: - raise DropItem(f"Missing price in {item}") + raise DropItem("Missing price") Write items to a JSON lines file @@ -254,7 +254,7 @@ returns multiples items with the same id: def process_item(self, item, spider): adapter = ItemAdapter(item) if adapter["id"] in self.ids_seen: - raise DropItem(f"Duplicate item found: {item!r}") + raise DropItem(f"Item ID already seen: {adapter['id']}") else: self.ids_seen.add(adapter["id"]) return item diff --git a/docs/topics/items.rst b/docs/topics/items.rst index 3c38ac2dc..f13a7b5b1 100644 --- a/docs/topics/items.rst +++ b/docs/topics/items.rst @@ -48,7 +48,7 @@ make it the most feature-complete item type: :class:`Item` objects replicate the standard :class:`dict` API, including its ``__init__`` method. - :class:`Item` allows defining field names, so that: + :class:`Item` allows the defining of field names, so that: - :class:`KeyError` is raised when using undefined field names (i.e. prevents typos going unnoticed) @@ -57,7 +57,7 @@ make it the most feature-complete item type: default even if the first scraped object does not have values for all of them - :class:`Item` also allows defining field metadata, which can be used to + :class:`Item` also allows the defining of field metadata, which can be used to :ref:`customize serialization `. :mod:`trackref` tracks :class:`Item` objects to help find memory leaks @@ -94,11 +94,11 @@ Dataclass objects .. versionadded:: 2.2 -:func:`~dataclasses.dataclass` allows defining item classes with field names, +:func:`~dataclasses.dataclass` allows the defining of item classes with field names, so that :ref:`item exporters ` can export all fields by default even if the first scraped object does not have values for all of them. -Additionally, ``dataclass`` items also allow to: +Additionally, ``dataclass`` items also allow you to: * define the type and default value of each defined field. @@ -126,7 +126,7 @@ attr.s objects .. versionadded:: 2.2 -:func:`attr.s` allows defining item classes with field names, +:func:`attr.s` allows the defining of item classes with field names, so that :ref:`item exporters ` can export all fields by default even if the first scraped object does not have values for all of them. @@ -399,12 +399,7 @@ In code that receives an item, such as methods of :ref:`item pipelines `, it is a good practice to use the :class:`~itemadapter.ItemAdapter` class and the :func:`~itemadapter.is_item` function to write code that works for -any :ref:`supported item type `: - -.. autoclass:: itemadapter.ItemAdapter - -.. autofunction:: itemadapter.is_item - +any supported item type. Other classes related to items ============================== diff --git a/docs/topics/link-extractors.rst b/docs/topics/link-extractors.rst index 1201c926d..f9744ed16 100644 --- a/docs/topics/link-extractors.rst +++ b/docs/topics/link-extractors.rst @@ -85,7 +85,7 @@ LxmlLinkExtractor :param restrict_xpaths: is an XPath (or list of XPath's) which defines regions inside the response where links should be extracted from. If given, only the text selected by those XPath will be scanned for - links. See examples below. + links. :type restrict_xpaths: str or list :param restrict_css: a CSS selector (or list of selectors) which defines diff --git a/docs/topics/media-pipeline.rst b/docs/topics/media-pipeline.rst index da0587aa4..c96dd0f99 100644 --- a/docs/topics/media-pipeline.rst +++ b/docs/topics/media-pipeline.rst @@ -532,14 +532,14 @@ See here the methods that you can override in your custom Files Pipeline: .. code-block:: python from pathlib import PurePosixPath - from urllib.parse import urlparse + from scrapy.utils.httpobj import urlparse_cached from scrapy.pipelines.files import FilesPipeline class MyFilesPipeline(FilesPipeline): def file_path(self, request, response=None, info=None, *, item=None): - return "files/" + PurePosixPath(urlparse(request.url).path).name + return "files/" + PurePosixPath(urlparse_cached(request).path).name Similarly, you can use the ``item`` to determine the file path based on some item property. @@ -690,14 +690,14 @@ See here the methods that you can override in your custom Images Pipeline: .. code-block:: python from pathlib import PurePosixPath - from urllib.parse import urlparse + from scrapy.utils.httpobj import urlparse_cached from scrapy.pipelines.images import ImagesPipeline class MyImagesPipeline(ImagesPipeline): def file_path(self, request, response=None, info=None, *, item=None): - return "files/" + PurePosixPath(urlparse(request.url).path).name + return "files/" + PurePosixPath(urlparse_cached(request).path).name Similarly, you can use the ``item`` to determine the file path based on some item property. diff --git a/docs/topics/practices.rst b/docs/topics/practices.rst index b1b8c9e9c..1500011e7 100644 --- a/docs/topics/practices.rst +++ b/docs/topics/practices.rst @@ -92,7 +92,6 @@ reactor after ``MySpider`` has finished running. .. code-block:: python - from twisted.internet import reactor import scrapy from scrapy.crawler import CrawlerRunner from scrapy.utils.log import configure_logging @@ -107,6 +106,37 @@ reactor after ``MySpider`` has finished running. runner = CrawlerRunner() d = runner.crawl(MySpider) + + from twisted.internet import reactor + + d.addBoth(lambda _: reactor.stop()) + reactor.run() # the script will block here until the crawling is finished + +Same example but using a non-default reactor, it's only necessary call +``install_reactor`` if you are using ``CrawlerRunner`` since ``CrawlerProcess`` already does this automatically. + +.. code-block:: python + + import scrapy + from scrapy.crawler import CrawlerRunner + from scrapy.utils.log import configure_logging + + + class MySpider(scrapy.Spider): + # Your spider definition + ... + + + configure_logging({"LOG_FORMAT": "%(levelname)s: %(message)s"}) + + from scrapy.utils.reactor import install_reactor + + install_reactor("twisted.internet.asyncioreactor.AsyncioSelectorReactor") + runner = CrawlerRunner() + d = runner.crawl(MySpider) + + from twisted.internet import reactor + d.addBoth(lambda _: reactor.stop()) reactor.run() # the script will block here until the crawling is finished @@ -151,7 +181,6 @@ Same example using :class:`~scrapy.crawler.CrawlerRunner`: .. code-block:: python import scrapy - from twisted.internet import reactor from scrapy.crawler import CrawlerRunner from scrapy.utils.log import configure_logging from scrapy.utils.project import get_project_settings @@ -173,6 +202,9 @@ Same example using :class:`~scrapy.crawler.CrawlerRunner`: runner.crawl(MySpider1) runner.crawl(MySpider2) d = runner.join() + + from twisted.internet import reactor + d.addBoth(lambda _: reactor.stop()) reactor.run() # the script will block here until all crawling jobs are finished @@ -181,7 +213,7 @@ Same example but running the spiders sequentially by chaining the deferreds: .. code-block:: python - from twisted.internet import reactor, defer + from twisted.internet import defer from scrapy.crawler import CrawlerRunner from scrapy.utils.log import configure_logging from scrapy.utils.project import get_project_settings @@ -209,6 +241,8 @@ Same example but running the spiders sequentially by chaining the deferreds: reactor.stop() + from twisted.internet import reactor + crawl() reactor.run() # the script will block here until the last crawl call is finished @@ -289,7 +323,8 @@ Here are some tips to keep in mind when dealing with these kinds of sites: services like `ProxyMesh`_. An open source alternative is `scrapoxy`_, a super proxy that you can attach your own proxies to. * use a ban avoidance service, such as `Zyte API`_, which provides a `Scrapy - plugin `__ + plugin `__ and additional + features, like `AI web scraping `__ If you are still unable to prevent your bot getting banned, consider contacting `commercial support`_. diff --git a/docs/topics/request-response.rst b/docs/topics/request-response.rst index 67fc0c6e9..3c2843bc1 100644 --- a/docs/topics/request-response.rst +++ b/docs/topics/request-response.rst @@ -94,13 +94,14 @@ Request objects .. code-block:: python request_with_cookies = Request( - url="http://www.example.com", + url="https://www.example.com", cookies=[ { "name": "currency", "value": "USD", "domain": "example.com", "path": "/currency", + "secure": True, }, ], ) @@ -677,6 +678,7 @@ Those are: * :reqmeta:`download_fail_on_dataloss` * :reqmeta:`download_latency` * :reqmeta:`download_maxsize` +* :reqmeta:`download_warnsize` * :reqmeta:`download_timeout` * ``ftp_password`` (See :setting:`FTP_PASSWORD` for more info) * ``ftp_user`` (See :setting:`FTP_USER` for more info) diff --git a/docs/topics/selectors.rst b/docs/topics/selectors.rst index 4a64d530b..0aae41cc8 100644 --- a/docs/topics/selectors.rst +++ b/docs/topics/selectors.rst @@ -1032,10 +1032,8 @@ whereas the CSS lookup is translated into XPath and thus runs more efficiently, so performance-wise its uses are limited to situations that are not easily described with CSS selectors. -Parsel also simplifies adding your own XPath extensions. - -.. autofunction:: parsel.xpathfuncs.set_xpathfunc - +Parsel also simplifies adding your own XPath extensions with +:func:`~parsel.xpathfuncs.set_xpathfunc`. .. _topics-selectors-ref: @@ -1062,6 +1060,12 @@ Selector objects For convenience, this method can be called as ``response.css()`` + .. automethod:: jmespath + + .. note:: + + For convenience, this method can be called as ``response.jmespath()`` + .. automethod:: get See also: :ref:`old-extraction-api` @@ -1094,6 +1098,8 @@ SelectorList objects .. automethod:: css + .. automethod:: jmespath + .. automethod:: getall See also: :ref:`old-extraction-api` diff --git a/docs/topics/settings.rst b/docs/topics/settings.rst index 06f8481ba..02fca7ff4 100644 --- a/docs/topics/settings.rst +++ b/docs/topics/settings.rst @@ -674,6 +674,7 @@ Default: .. code-block:: python { + "scrapy.downloadermiddlewares.offsite.OffsiteMiddleware": 50, "scrapy.downloadermiddlewares.robotstxt.RobotsTxtMiddleware": 100, "scrapy.downloadermiddlewares.httpauth.HttpAuthMiddleware": 300, "scrapy.downloadermiddlewares.downloadtimeout.DownloadTimeoutMiddleware": 350, @@ -835,7 +836,7 @@ The default HTTPS handler uses HTTP/1.1. To use HTTP/2: .. setting:: DOWNLOAD_SLOTS DOWNLOAD_SLOTS ----------------- +-------------- Default: ``{}`` @@ -844,7 +845,12 @@ Allows to define concurrency/delay parameters on per slot (domain) basis: .. code-block:: python DOWNLOAD_SLOTS = { - "quotes.toscrape.com": {"concurrency": 1, "delay": 2, "randomize_delay": False}, + "quotes.toscrape.com": { + "concurrency": 1, + "delay": 2, + "randomize_delay": False, + "throttle": False, + }, "books.toscrape.com": {"delay": 3, "randomize_delay": False}, } @@ -856,6 +862,9 @@ Allows to define concurrency/delay parameters on per slot (domain) basis: - :setting:`CONCURRENT_REQUESTS_PER_DOMAIN`: ``concurrency`` - :setting:`RANDOMIZE_DOWNLOAD_DELAY`: ``randomize_delay`` + There is no global setting for ``throttle``, whose default value is + ``None``. + .. setting:: DOWNLOAD_TIMEOUT @@ -873,40 +882,42 @@ The amount of time (in secs) that the downloader will wait before timing out. Request.meta key. .. setting:: DOWNLOAD_MAXSIZE +.. reqmeta:: download_maxsize DOWNLOAD_MAXSIZE ---------------- -Default: ``1073741824`` (1024MB) +Default: ``1073741824`` (1 GiB) -The maximum response size (in bytes) that downloader will download. +The maximum response body size (in bytes) allowed. Bigger responses are +aborted and ignored. -If you want to disable it set to 0. +This applies both before and after compression. If decompressing a response +body would exceed this limit, decompression is aborted and the response is +ignored. -.. reqmeta:: download_maxsize +Use ``0`` to disable this limit. -.. note:: - - This size can be set per spider using :attr:`download_maxsize` - spider attribute and per-request using :reqmeta:`download_maxsize` - Request.meta key. +This limit can be set per spider using the :attr:`download_maxsize` spider +attribute and per request using the :reqmeta:`download_maxsize` Request.meta +key. .. setting:: DOWNLOAD_WARNSIZE +.. reqmeta:: download_warnsize DOWNLOAD_WARNSIZE ----------------- -Default: ``33554432`` (32MB) +Default: ``33554432`` (32 MiB) -The response size (in bytes) that downloader will start to warn. +If the size of a response exceeds this value, before or after compression, a +warning will be logged about it. -If you want to disable it set to 0. +Use ``0`` to disable this limit. -.. note:: - - This size can be set per spider using :attr:`download_warnsize` - spider attribute and per-request using :reqmeta:`download_warnsize` - Request.meta key. +This limit can be set per spider using the :attr:`download_warnsize` spider +attribute and per request using the :reqmeta:`download_warnsize` Request.meta +key. .. setting:: DOWNLOAD_FAIL_ON_DATALOSS @@ -1569,7 +1580,7 @@ SPIDER_LOADER_WARN_ONLY Default: ``False`` By default, when Scrapy tries to import spider classes from :setting:`SPIDER_MODULES`, -it will fail loudly if there is any ``ImportError`` exception. +it will fail loudly if there is any ``ImportError`` or ``SyntaxError`` exception. But you can choose to silence this exception and turn it into a simple warning by setting ``SPIDER_LOADER_WARN_ONLY = True``. @@ -1603,7 +1614,6 @@ Default: { "scrapy.spidermiddlewares.httperror.HttpErrorMiddleware": 50, - "scrapy.spidermiddlewares.offsite.OffsiteMiddleware": 500, "scrapy.spidermiddlewares.referer.RefererMiddleware": 700, "scrapy.spidermiddlewares.urllength.UrlLengthMiddleware": 800, "scrapy.spidermiddlewares.depth.DepthMiddleware": 900, diff --git a/docs/topics/signals.rst b/docs/topics/signals.rst index 9bfd1761c..b45b12540 100644 --- a/docs/topics/signals.rst +++ b/docs/topics/signals.rst @@ -159,8 +159,9 @@ item_scraped :param spider: the spider which scraped the item :type spider: :class:`~scrapy.Spider` object - :param response: the response from where the item was scraped - :type response: :class:`~scrapy.http.Response` object + :param response: the response from where the item was scraped, or ``None`` + if it was yielded from :meth:`~scrapy.Spider.start_requests`. + :type response: :class:`~scrapy.http.Response` | ``None`` item_dropped ~~~~~~~~~~~~ @@ -179,8 +180,9 @@ item_dropped :param spider: the spider which scraped the item :type spider: :class:`~scrapy.Spider` object - :param response: the response from where the item was dropped - :type response: :class:`~scrapy.http.Response` object + :param response: the response from where the item was dropped, or ``None`` + if it was yielded from :meth:`~scrapy.Spider.start_requests`. + :type response: :class:`~scrapy.http.Response` | ``None`` :param exception: the exception (which must be a :exc:`~scrapy.exceptions.DropItem` subclass) which caused the item @@ -201,8 +203,10 @@ item_error :param item: the item that caused the error in the :ref:`topics-item-pipeline` :type item: :ref:`item object ` - :param response: the response being processed when the exception was raised - :type response: :class:`~scrapy.http.Response` object + :param response: the response being processed when the exception was + raised, or ``None`` if it was yielded from + :meth:`~scrapy.Spider.start_requests`. + :type response: :class:`~scrapy.http.Response` | ``None`` :param spider: the spider which raised the exception :type spider: :class:`~scrapy.Spider` object @@ -343,11 +347,18 @@ request_scheduled .. signal:: request_scheduled .. function:: request_scheduled(request, spider) - Sent when the engine schedules a :class:`~scrapy.Request`, to be - downloaded later. + Sent when the engine is asked to schedule a :class:`~scrapy.Request`, to be + downloaded later, before the request reaches the :ref:`scheduler + `. + + Raise :exc:`~scrapy.exceptions.IgnoreRequest` to drop a request before it + reaches the scheduler. This signal does not support returning deferreds from its handlers. + .. versionadded:: 2.11.2 + Allow dropping requests with :exc:`~scrapy.exceptions.IgnoreRequest`. + :param request: the request that reached the scheduler :type request: :class:`~scrapy.Request` object diff --git a/docs/topics/spider-middleware.rst b/docs/topics/spider-middleware.rst index 3f16efea5..8f39bcd53 100644 --- a/docs/topics/spider-middleware.rst +++ b/docs/topics/spider-middleware.rst @@ -51,8 +51,8 @@ value. For example, if you want to disable the off-site middleware: .. code-block:: python SPIDER_MIDDLEWARES = { - "myproject.middlewares.CustomSpiderMiddleware": 543, - "scrapy.spidermiddlewares.offsite.OffsiteMiddleware": None, + "scrapy.spidermiddlewares.referer.RefererMiddleware": None, + "myproject.middlewares.CustomRefererSpiderMiddleware": 700, } Finally, keep in mind that some middlewares may need to be enabled through a @@ -176,7 +176,7 @@ object gives you access, for example, to the :ref:`settings `. items). It receives an iterable (in the ``start_requests`` parameter) and must - return another iterable of :class:`~scrapy.Request` objects. + return another iterable of :class:`~scrapy.Request` objects and/or :ref:`item objects `. .. note:: When implementing this method in your spider middleware, you should always return an iterable (that follows the input one) and @@ -313,42 +313,6 @@ Default: ``False`` Pass all responses, regardless of its status code. -OffsiteMiddleware ------------------ - -.. module:: scrapy.spidermiddlewares.offsite - :synopsis: Offsite Spider Middleware - -.. class:: OffsiteMiddleware - - Filters out Requests for URLs outside the domains covered by the spider. - - This middleware filters out every request whose host names aren't in the - spider's :attr:`~scrapy.Spider.allowed_domains` attribute. - All subdomains of any domain in the list are also allowed. - E.g. the rule ``www.example.org`` will also allow ``bob.www.example.org`` - but not ``www2.example.com`` nor ``example.com``. - - When your spider returns a request for a domain not belonging to those - covered by the spider, this middleware will log a debug message similar to - this one:: - - DEBUG: Filtered offsite request to 'www.othersite.com': - - To avoid filling the log with too much noise, it will only print one of - these messages for each new domain filtered. So, for example, if another - request for ``www.othersite.com`` is filtered, no log message will be - printed. But if a request for ``someothersite.com`` is filtered, a message - will be printed (but only for the first request filtered). - - If the spider doesn't define an - :attr:`~scrapy.Spider.allowed_domains` attribute, or the - attribute is empty, the offsite middleware will allow all requests. - - If the request has the :attr:`~scrapy.Request.dont_filter` attribute - set, the offsite middleware will allow the request even if its domain is not - listed in allowed domains. - RefererMiddleware ----------------- diff --git a/docs/topics/spiders.rst b/docs/topics/spiders.rst index 30677fe74..e1b1c5ad6 100644 --- a/docs/topics/spiders.rst +++ b/docs/topics/spiders.rst @@ -75,7 +75,8 @@ scrapy.Spider An optional list of strings containing domains that this spider is allowed to crawl. Requests for URLs not belonging to the domain names specified in this list (or their subdomains) won't be followed if - :class:`~scrapy.spidermiddlewares.offsite.OffsiteMiddleware` is enabled. + :class:`~scrapy.downloadermiddlewares.offsite.OffsiteMiddleware` is + enabled. Let's say your target url is ``https://www.example.com/1.html``, then add ``'example.com'`` to the list. @@ -202,7 +203,8 @@ scrapy.Spider .. method:: start_requests() - This method must return an iterable with the first Requests to crawl for + This method must return an iterable with the first Requests to crawl and/or with :ref:`item objects + ` for this spider. It is called by Scrapy when the spider is opened for scraping. Scrapy calls it only once, so it is safe to implement :meth:`start_requests` as a generator. diff --git a/docs/topics/telnetconsole.rst b/docs/topics/telnetconsole.rst index 832829b75..0e4a8fa6c 100644 --- a/docs/topics/telnetconsole.rst +++ b/docs/topics/telnetconsole.rst @@ -172,8 +172,8 @@ TELNETCONSOLE_PORT Default: ``[6023, 6073]`` -The port range to use for the telnet console. If set to ``None`` or ``0``, a -dynamically assigned port is used. +The port range to use for the telnet console. If set to ``None``, a dynamically +assigned port is used. .. setting:: TELNETCONSOLE_HOST diff --git a/pylintrc b/pylintrc index c8654b8d3..c60e4e16a 100644 --- a/pylintrc +++ b/pylintrc @@ -4,21 +4,14 @@ jobs=1 # >1 hides results [MESSAGES CONTROL] disable=abstract-method, - anomalous-backslash-in-string, arguments-differ, arguments-renamed, attribute-defined-outside-init, bad-classmethod-argument, - bad-mcs-classmethod-argument, bare-except, broad-except, broad-exception-raised, c-extension-no-member, - catching-non-exception, - cell-var-from-loop, - comparison-with-callable, - consider-using-dict-items, - consider-using-in, consider-using-with, cyclic-import, dangerous-default-value, @@ -32,7 +25,6 @@ disable=abstract-method, implicit-str-concat, import-error, import-outside-toplevel, - import-self, inconsistent-return-statements, inherit-non-class, invalid-name, @@ -44,7 +36,6 @@ disable=abstract-method, logging-fstring-interpolation, logging-not-lazy, lost-exception, - method-hidden, missing-docstring, no-else-raise, no-else-return, @@ -52,7 +43,7 @@ disable=abstract-method, no-method-argument, no-name-in-module, no-self-argument, - no-value-for-parameter, + no-value-for-parameter, # https://github.com/pylint-dev/pylint/issues/3268 not-callable, pointless-exception-statement, pointless-statement, @@ -77,23 +68,15 @@ disable=abstract-method, too-many-public-methods, too-many-return-statements, unbalanced-tuple-unpacking, - undefined-variable, - undefined-loop-variable, - unexpected-special-method-signature, - unnecessary-comprehension, unnecessary-dunder-call, unnecessary-pass, unreachable, - unsubscriptable-object, unused-argument, unused-import, unused-private-member, unused-variable, unused-wildcard-import, - use-dict-literal, used-before-assignment, - useless-object-inheritance, # Required for Python 2 support useless-return, - useless-super-delegation, wildcard-import, wrong-import-position diff --git a/scrapy/VERSION b/scrapy/VERSION index 46b81d815..9e5bb77a3 100644 --- a/scrapy/VERSION +++ b/scrapy/VERSION @@ -1 +1 @@ -2.11.0 +2.11.2 diff --git a/scrapy/__init__.py b/scrapy/__init__.py index cc0e539c4..1c1a5c2cc 100644 --- a/scrapy/__init__.py +++ b/scrapy/__init__.py @@ -33,12 +33,6 @@ version_info = tuple(int(v) if v.isdigit() else v for v in __version__.split("." twisted_version = (_txv.major, _txv.minor, _txv.micro) -# Check minimum required Python version -if sys.version_info < (3, 8): - print(f"Scrapy {__version__} requires Python 3.8+") - sys.exit(1) - - # Ignore noisy twisted deprecation warnings warnings.filterwarnings("ignore", category=DeprecationWarning, module="twisted") diff --git a/scrapy/addons.py b/scrapy/addons.py index 65d7a0310..7a1da3afc 100644 --- a/scrapy/addons.py +++ b/scrapy/addons.py @@ -1,13 +1,16 @@ +from __future__ import annotations + import logging -from typing import TYPE_CHECKING, Any, List +from typing import TYPE_CHECKING, Any from scrapy.exceptions import NotConfigured -from scrapy.settings import Settings from scrapy.utils.conf import build_component_list from scrapy.utils.misc import build_from_crawler, load_object if TYPE_CHECKING: from scrapy.crawler import Crawler + from scrapy.settings import Settings + logger = logging.getLogger(__name__) @@ -15,9 +18,9 @@ logger = logging.getLogger(__name__) class AddonManager: """This class facilitates loading and storing :ref:`topics-addons`.""" - def __init__(self, crawler: "Crawler") -> None: - self.crawler: "Crawler" = crawler - self.addons: List[Any] = [] + def __init__(self, crawler: Crawler) -> None: + self.crawler: Crawler = crawler + self.addons: list[Any] = [] def load_settings(self, settings: Settings) -> None: """Load add-ons and configurations from a settings object and apply them. diff --git a/scrapy/cmdline.py b/scrapy/cmdline.py index 6580ba9ce..b6f19a37f 100644 --- a/scrapy/cmdline.py +++ b/scrapy/cmdline.py @@ -1,9 +1,12 @@ +from __future__ import annotations + import argparse import cProfile import inspect import os import sys from importlib.metadata import entry_points +from typing import TYPE_CHECKING import scrapy from scrapy.commands import BaseRunSpiderCommand, ScrapyCommand, ScrapyHelpFormatter @@ -13,9 +16,21 @@ from scrapy.utils.misc import walk_modules from scrapy.utils.project import get_project_settings, inside_project from scrapy.utils.python import garbage_collect +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") + class ScrapyArgumentParser(argparse.ArgumentParser): - def _parse_optional(self, arg_string): + def _parse_optional( + self, arg_string: str + ) -> tuple[argparse.Action | None, str, str | None] | None: # if starts with -: it means that is a parameter not a argument if arg_string[:2] == "-:": return None @@ -23,7 +38,7 @@ class ScrapyArgumentParser(argparse.ArgumentParser): return super()._parse_optional(arg_string) -def _iter_command_classes(module_name): +def _iter_command_classes(module_name: str) -> Iterable[type[ScrapyCommand]]: # TODO: add `name` attribute to commands and merge this function with # scrapy.utils.spider.iter_spider_classes for module in walk_modules(module_name): @@ -37,8 +52,8 @@ def _iter_command_classes(module_name): yield obj -def _get_commands_from_module(module, inproject): - d = {} +def _get_commands_from_module(module: str, inproject: bool) -> dict[str, ScrapyCommand]: + d: dict[str, ScrapyCommand] = {} for cmd in _iter_command_classes(module): if inproject or not cmd.requires_project: cmdname = cmd.__module__.split(".")[-1] @@ -46,8 +61,10 @@ def _get_commands_from_module(module, inproject): return d -def _get_commands_from_entry_points(inproject, group="scrapy.commands"): - cmds = {} +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: @@ -61,7 +78,9 @@ def _get_commands_from_entry_points(inproject, group="scrapy.commands"): return cmds -def _get_commands_dict(settings, inproject): +def _get_commands_dict( + settings: BaseSettings, inproject: bool +) -> dict[str, ScrapyCommand]: cmds = _get_commands_from_module("scrapy.commands", inproject) cmds.update(_get_commands_from_entry_points(inproject)) cmds_module = settings["COMMANDS_MODULE"] @@ -70,16 +89,17 @@ def _get_commands_dict(settings, inproject): return cmds -def _pop_command_name(argv): +def _pop_command_name(argv: list[str]) -> str | None: i = 0 for arg in argv[1:]: if not arg.startswith("-"): del argv[i] return arg i += 1 + return None -def _print_header(settings, inproject): +def _print_header(settings: BaseSettings, inproject: bool) -> None: version = scrapy.__version__ if inproject: print(f"Scrapy {version} - active project: {settings['BOT_NAME']}\n") @@ -88,7 +108,7 @@ def _print_header(settings, inproject): print(f"Scrapy {version} - no active project\n") -def _print_commands(settings, inproject): +def _print_commands(settings: BaseSettings, inproject: bool) -> None: _print_header(settings, inproject) print("Usage:") print(" scrapy [options] [args]\n") @@ -103,13 +123,20 @@ def _print_commands(settings, inproject): print('Use "scrapy -h" to see more info about a command') -def _print_unknown_command(settings, cmdname, inproject): +def _print_unknown_command( + settings: BaseSettings, cmdname: str, inproject: bool +) -> None: _print_header(settings, inproject) print(f"Unknown command: {cmdname}\n") print('Use "scrapy" to see available commands') -def _run_print_help(parser, func, *a, **kw): +def _run_print_help( + parser: argparse.ArgumentParser, + func: Callable[_P, None], + *a: _P.args, + **kw: _P.kwargs, +) -> None: try: func(*a, **kw) except UsageError as e: @@ -120,7 +147,7 @@ def _run_print_help(parser, func, *a, **kw): sys.exit(2) -def execute(argv=None, settings=None): +def execute(argv: list[str] | None = None, settings: Settings | None = None) -> None: if argv is None: argv = sys.argv @@ -162,14 +189,16 @@ def execute(argv=None, settings=None): sys.exit(cmd.exitcode) -def _run_command(cmd, args, opts): +def _run_command(cmd: ScrapyCommand, args: list[str], opts: argparse.Namespace) -> None: if opts.profile: _run_command_profiled(cmd, args, opts) else: cmd.run(args, opts) -def _run_command_profiled(cmd, args, opts): +def _run_command_profiled( + cmd: ScrapyCommand, args: list[str], opts: argparse.Namespace +) -> None: if opts.profile: sys.stderr.write(f"scrapy: writing cProfile stats to {opts.profile!r}\n") loc = locals() diff --git a/scrapy/commands/__init__.py b/scrapy/commands/__init__.py index 2aa569cdd..eccbef040 100644 --- a/scrapy/commands/__init__.py +++ b/scrapy/commands/__init__.py @@ -1,62 +1,70 @@ """ Base class for Scrapy commands """ + +from __future__ import annotations + import argparse +import builtins import os from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import TYPE_CHECKING, Any from twisted.python import failure -from scrapy.crawler import CrawlerProcess from scrapy.exceptions import UsageError from scrapy.utils.conf import arglist_to_dict, feed_process_params_from_cli +if TYPE_CHECKING: + from collections.abc import Iterable + + from scrapy.crawler import Crawler, CrawlerProcess + class ScrapyCommand: - requires_project = False - crawler_process: Optional[CrawlerProcess] = None + requires_project: bool = False + crawler_process: CrawlerProcess | None = None # default settings to be used for this command instead of global defaults - default_settings: Dict[str, Any] = {} + default_settings: dict[str, Any] = {} - exitcode = 0 + exitcode: int = 0 def __init__(self) -> None: self.settings: Any = None # set in scrapy.cmdline - def set_crawler(self, crawler): + def set_crawler(self, crawler: Crawler) -> None: if hasattr(self, "_crawler"): raise RuntimeError("crawler already set") - self._crawler = crawler + self._crawler: Crawler = crawler - def syntax(self): + def syntax(self) -> str: """ Command syntax (preferably one-line). Do not include command name. """ return "" - def short_desc(self): + def short_desc(self) -> str: """ A short description of the command """ return "" - def long_desc(self): + def long_desc(self) -> str: """A long description of the command. Return short description when not available. It cannot contain newlines since contents will be formatted by optparser which removes newlines and wraps text. """ return self.short_desc() - def help(self): + def help(self) -> str: """An extensive help for the command. It will be shown when using the "help" command. It can contain newlines since no post-formatting will be applied to its contents. """ return self.long_desc() - def add_options(self, parser): + def add_options(self, parser: argparse.ArgumentParser) -> None: """ Populate option parse with options available for this command """ @@ -91,7 +99,7 @@ class ScrapyCommand: ) group.add_argument("--pdb", action="store_true", help="enable pdb on failure") - def process_options(self, args, opts): + def process_options(self, args: list[str], opts: argparse.Namespace) -> None: try: self.settings.setdict(arglist_to_dict(opts.set), priority="cmdline") except ValueError: @@ -116,7 +124,7 @@ class ScrapyCommand: if opts.pdb: failure.startDebugMode() - def run(self, args: List[str], opts: argparse.Namespace) -> None: + def run(self, args: list[str], opts: argparse.Namespace) -> None: """ Entry point for running commands """ @@ -128,8 +136,8 @@ class BaseRunSpiderCommand(ScrapyCommand): Common class used to share functionality between the crawl, parse and runspider commands """ - def add_options(self, parser): - ScrapyCommand.add_options(self, parser) + def add_options(self, parser: argparse.ArgumentParser) -> None: + super().add_options(parser) parser.add_argument( "-a", dest="spargs", @@ -161,8 +169,8 @@ class BaseRunSpiderCommand(ScrapyCommand): help="format to use for dumping items", ) - def process_options(self, args, opts): - ScrapyCommand.process_options(self, args, opts) + def process_options(self, args: list[str], opts: argparse.Namespace) -> None: + super().process_options(args, opts) try: opts.spargs = arglist_to_dict(opts.spargs) except ValueError: @@ -182,7 +190,13 @@ class ScrapyHelpFormatter(argparse.HelpFormatter): Help Formatter for scrapy command line help messages. """ - def __init__(self, prog, indent_increment=2, max_help_position=24, width=None): + def __init__( + self, + prog: str, + indent_increment: int = 2, + max_help_position: int = 24, + width: int | None = None, + ): super().__init__( prog, indent_increment=indent_increment, @@ -190,11 +204,12 @@ class ScrapyHelpFormatter(argparse.HelpFormatter): width=width, ) - def _join_parts(self, part_strings): - parts = self.format_part_strings(part_strings) + def _join_parts(self, part_strings: Iterable[str]) -> str: + # scrapy.commands.list shadows builtins.list + parts = self.format_part_strings(builtins.list(part_strings)) return super()._join_parts(parts) - def format_part_strings(self, part_strings): + def format_part_strings(self, part_strings: list[str]) -> list[str]: """ Underline and title case command line help message headers. """ diff --git a/scrapy/commands/bench.py b/scrapy/commands/bench.py index e1ccdc451..4f6933006 100644 --- a/scrapy/commands/bench.py +++ b/scrapy/commands/bench.py @@ -1,12 +1,22 @@ -import subprocess +from __future__ import annotations + +import argparse +import subprocess # nosec import sys import time +from typing import TYPE_CHECKING, Any from urllib.parse import urlencode import scrapy from scrapy.commands import ScrapyCommand +from scrapy.http import Response, TextResponse from scrapy.linkextractors import LinkExtractor +if TYPE_CHECKING: + from collections.abc import Iterable + + from scrapy import Request + class Command(ScrapyCommand): default_settings = { @@ -15,24 +25,28 @@ class Command(ScrapyCommand): "CLOSESPIDER_TIMEOUT": 10, } - def short_desc(self): + def short_desc(self) -> str: return "Run quick benchmark test" - def run(self, args, opts): + def run(self, args: list[str], opts: argparse.Namespace) -> None: with _BenchServer(): + assert self.crawler_process self.crawler_process.crawl(_BenchSpider, total=100000) self.crawler_process.start() class _BenchServer: - def __enter__(self): + def __enter__(self) -> None: from scrapy.utils.test import get_testenv pargs = [sys.executable, "-u", "-m", "scrapy.utils.benchserver"] - self.proc = subprocess.Popen(pargs, stdout=subprocess.PIPE, env=get_testenv()) + self.proc = subprocess.Popen( + pargs, stdout=subprocess.PIPE, env=get_testenv() + ) # nosec + assert self.proc.stdout self.proc.stdout.readline() - def __exit__(self, exc_type, exc_value, traceback): + def __exit__(self, exc_type, exc_value, traceback) -> None: self.proc.kill() self.proc.wait() time.sleep(0.2) @@ -47,11 +61,12 @@ class _BenchSpider(scrapy.Spider): baseurl = "http://localhost:8998" link_extractor = LinkExtractor() - def start_requests(self): + def start_requests(self) -> Iterable[Request]: qargs = {"total": self.total, "show": self.show} url = f"{self.baseurl}?{urlencode(qargs, doseq=True)}" return [scrapy.Request(url, dont_filter=True)] - def parse(self, response): + def parse(self, response: Response) -> Any: + assert isinstance(Response, TextResponse) for link in self.link_extractor.extract_links(response): yield scrapy.Request(link.url, callback=self.parse) diff --git a/scrapy/commands/check.py b/scrapy/commands/check.py index de54ca4d3..1ce155da7 100644 --- a/scrapy/commands/check.py +++ b/scrapy/commands/check.py @@ -1,3 +1,4 @@ +import argparse import time from collections import defaultdict from unittest import TextTestResult as _TextTestResult @@ -10,7 +11,7 @@ from scrapy.utils.misc import load_object, set_environ class TextTestResult(_TextTestResult): - def printSummary(self, start, stop): + def printSummary(self, start: float, stop: float) -> None: write = self.stream.write writeln = self.stream.writeln @@ -42,14 +43,14 @@ class Command(ScrapyCommand): requires_project = True default_settings = {"LOG_ENABLED": False} - def syntax(self): + def syntax(self) -> str: return "[options] " - def short_desc(self): + def short_desc(self) -> str: return "Check spider contracts" - def add_options(self, parser): - ScrapyCommand.add_options(self, parser) + def add_options(self, parser: argparse.ArgumentParser) -> None: + super().add_options(parser) parser.add_argument( "-l", "--list", @@ -66,7 +67,7 @@ class Command(ScrapyCommand): help="print contract tests for all spiders", ) - def run(self, args, opts): + def run(self, args: list[str], opts: argparse.Namespace) -> None: # load contracts contracts = build_component_list(self.settings.getwithbase("SPIDER_CONTRACTS")) conman = ContractsManager(load_object(c) for c in contracts) @@ -76,12 +77,13 @@ class Command(ScrapyCommand): # contract requests contract_reqs = defaultdict(list) + assert self.crawler_process spider_loader = self.crawler_process.spider_loader with set_environ(SCRAPY_CHECK="true"): for spidername in args or spider_loader.list(): spidercls = spider_loader.load(spidername) - spidercls.start_requests = lambda s: conman.from_spider(s, result) + spidercls.start_requests = lambda s: conman.from_spider(s, result) # type: ignore[assignment,method-assign,return-value] tested_methods = conman.tested_methods_from_spidercls(spidercls) if opts.list: diff --git a/scrapy/commands/crawl.py b/scrapy/commands/crawl.py index 2f0f1c7b9..6b6a80bb5 100644 --- a/scrapy/commands/crawl.py +++ b/scrapy/commands/crawl.py @@ -1,17 +1,26 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +from twisted.python.failure import Failure + from scrapy.commands import BaseRunSpiderCommand from scrapy.exceptions import UsageError +if TYPE_CHECKING: + import argparse + class Command(BaseRunSpiderCommand): requires_project = True - def syntax(self): + def syntax(self) -> str: return "[options] " - def short_desc(self): + def short_desc(self) -> str: return "Run a spider" - def run(self, args, opts): + def run(self, args: list[str], opts: argparse.Namespace) -> None: if len(args) < 1: raise UsageError() elif len(args) > 1: @@ -20,10 +29,11 @@ class Command(BaseRunSpiderCommand): ) spname = args[0] + assert self.crawler_process crawl_defer = self.crawler_process.crawl(spname, **opts.spargs) if getattr(crawl_defer, "result", None) is not None and issubclass( - crawl_defer.result.type, Exception + cast(Failure, crawl_defer.result).type, Exception ): self.exitcode = 1 else: diff --git a/scrapy/commands/edit.py b/scrapy/commands/edit.py index 03a8ed5c7..34313d731 100644 --- a/scrapy/commands/edit.py +++ b/scrapy/commands/edit.py @@ -1,3 +1,4 @@ +import argparse import os import sys @@ -9,32 +10,34 @@ class Command(ScrapyCommand): requires_project = True default_settings = {"LOG_ENABLED": False} - def syntax(self): + def syntax(self) -> str: return "" - def short_desc(self): + def short_desc(self) -> str: return "Edit spider" - def long_desc(self): + def long_desc(self) -> str: return ( "Edit a spider using the editor defined in the EDITOR environment" " variable or else the EDITOR setting" ) - def _err(self, msg): + def _err(self, msg: str) -> None: sys.stderr.write(msg + os.linesep) self.exitcode = 1 - def run(self, args, opts): + def run(self, args: list[str], opts: argparse.Namespace) -> None: if len(args) != 1: raise UsageError() editor = self.settings["EDITOR"] + assert self.crawler_process try: spidercls = self.crawler_process.spider_loader.load(args[0]) except KeyError: return self._err(f"Spider not found: {args[0]}") sfile = sys.modules[spidercls.__module__].__file__ + assert sfile sfile = sfile.replace(".pyc", ".py") - self.exitcode = os.system(f'{editor} "{sfile}"') + self.exitcode = os.system(f'{editor} "{sfile}"') # nosec diff --git a/scrapy/commands/fetch.py b/scrapy/commands/fetch.py index cdb7ad4ae..a1806f626 100644 --- a/scrapy/commands/fetch.py +++ b/scrapy/commands/fetch.py @@ -1,34 +1,38 @@ +from __future__ import annotations + import sys -from argparse import Namespace -from typing import List, Type +from typing import TYPE_CHECKING from w3lib.url import is_url from scrapy import Spider from scrapy.commands import ScrapyCommand from scrapy.exceptions import UsageError -from scrapy.http import Request +from scrapy.http import Request, Response from scrapy.utils.datatypes import SequenceExclude from scrapy.utils.spider import DefaultSpider, spidercls_for_request +if TYPE_CHECKING: + from argparse import ArgumentParser, Namespace + class Command(ScrapyCommand): requires_project = False - def syntax(self): + def syntax(self) -> str: return "[options] " - def short_desc(self): + def short_desc(self) -> str: return "Fetch a URL using the Scrapy downloader" - def long_desc(self): + def long_desc(self) -> str: return ( "Fetch a URL using the Scrapy downloader and print its content" " to stdout. You may want to use --nolog to disable logging" ) - def add_options(self, parser): - ScrapyCommand.add_options(self, parser) + def add_options(self, parser: ArgumentParser) -> None: + super().add_options(parser) parser.add_argument("--spider", dest="spider", help="use this spider") parser.add_argument( "--headers", @@ -44,23 +48,24 @@ class Command(ScrapyCommand): help="do not handle HTTP 3xx status codes and print response as-is", ) - def _print_headers(self, headers, prefix): + def _print_headers(self, headers: dict[bytes, list[bytes]], prefix: bytes) -> None: for key, values in headers.items(): for value in values: self._print_bytes(prefix + b" " + key + b": " + value) - def _print_response(self, response, opts): + def _print_response(self, response: Response, opts: Namespace) -> None: if opts.headers: + assert response.request self._print_headers(response.request.headers, b">") print(">") self._print_headers(response.headers, b"<") else: self._print_bytes(response.body) - def _print_bytes(self, bytes_): + def _print_bytes(self, bytes_: bytes) -> None: sys.stdout.buffer.write(bytes_ + b"\n") - def run(self, args: List[str], opts: Namespace) -> None: + def run(self, args: list[str], opts: Namespace) -> None: if len(args) != 1 or not is_url(args[0]): raise UsageError() request = Request( @@ -76,7 +81,7 @@ class Command(ScrapyCommand): else: request.meta["handle_httpstatus_all"] = True - spidercls: Type[Spider] = DefaultSpider + spidercls: type[Spider] = DefaultSpider assert self.crawler_process spider_loader = self.crawler_process.spider_loader if opts.spider: diff --git a/scrapy/commands/genspider.py b/scrapy/commands/genspider.py index 68cbe8ff6..b286e703e 100644 --- a/scrapy/commands/genspider.py +++ b/scrapy/commands/genspider.py @@ -1,9 +1,12 @@ +from __future__ import annotations + +import argparse import os import shutil import string from importlib import import_module from pathlib import Path -from typing import Optional, cast +from typing import Any, cast from urllib.parse import urlparse import scrapy @@ -12,7 +15,7 @@ from scrapy.exceptions import UsageError from scrapy.utils.template import render_templatefile, string_camelcase -def sanitize_module_name(module_name): +def sanitize_module_name(module_name: str) -> str: """Sanitize the given module name, by replacing dashes and points with underscores and prefixing it with a letter if it doesn't start with one @@ -23,7 +26,7 @@ def sanitize_module_name(module_name): return module_name -def extract_domain(url): +def extract_domain(url: str) -> str: """Extract domain name from URL string""" o = urlparse(url) if o.scheme == "" and o.netloc == "": @@ -31,7 +34,7 @@ def extract_domain(url): return o.netloc -def verify_url_scheme(url): +def verify_url_scheme(url: str) -> str: """Check url for scheme and insert https if none found.""" parsed = urlparse(url) if parsed.scheme == "" and parsed.netloc == "": @@ -43,14 +46,14 @@ class Command(ScrapyCommand): requires_project = False default_settings = {"LOG_ENABLED": False} - def syntax(self): + def syntax(self) -> str: return "[options] " - def short_desc(self): + def short_desc(self) -> str: return "Generate new spider using pre-defined templates" - def add_options(self, parser): - ScrapyCommand.add_options(self, parser) + def add_options(self, parser: argparse.ArgumentParser) -> None: + super().add_options(parser) parser.add_argument( "-l", "--list", @@ -86,7 +89,7 @@ class Command(ScrapyCommand): help="If the spider already exists, overwrite it with the template", ) - def run(self, args, opts): + def run(self, args: list[str], opts: argparse.Namespace) -> None: if opts.list: self._list_templates() return @@ -113,23 +116,39 @@ class Command(ScrapyCommand): if template_file: self._genspider(module, name, url, opts.template, template_file) if opts.edit: - self.exitcode = os.system(f'scrapy edit "{name}"') + self.exitcode = os.system(f'scrapy edit "{name}"') # nosec - def _genspider(self, module, name, url, template_name, template_file): - """Generate the spider module, based on the given template""" + def _generate_template_variables( + self, + module: str, + name: str, + url: str, + template_name: str, + ) -> dict[str, Any]: capitalized_module = "".join(s.capitalize() for s in module.split("_")) - domain = extract_domain(url) - tvars = { + return { "project_name": self.settings.get("BOT_NAME"), "ProjectName": string_camelcase(self.settings.get("BOT_NAME")), "module": module, "name": name, "url": url, - "domain": domain, + "domain": extract_domain(url), "classname": f"{capitalized_module}Spider", } + + def _genspider( + self, + module: str, + name: str, + url: str, + template_name: str, + template_file: str | os.PathLike, + ) -> None: + """Generate the spider module, based on the given template""" + tvars = self._generate_template_variables(module, name, url, template_name) if self.settings.get("NEWSPIDER_MODULE"): spiders_module = import_module(self.settings["NEWSPIDER_MODULE"]) + assert spiders_module.__file__ spiders_dir = Path(spiders_module.__file__).parent.resolve() else: spiders_module = None @@ -144,7 +163,7 @@ class Command(ScrapyCommand): if spiders_module: print(f"in module:\n {spiders_module.__name__}.{module}") - def _find_template(self, template: str) -> Optional[Path]: + def _find_template(self, template: str) -> Path | None: template_file = Path(self.templates_dir, f"{template}.tmpl") if template_file.exists(): return template_file @@ -152,7 +171,7 @@ class Command(ScrapyCommand): print('Use "scrapy genspider --list" to see all available templates.') return None - def _list_templates(self): + def _list_templates(self) -> None: print("Available templates:") for file in sorted(Path(self.templates_dir).iterdir()): if file.suffix == ".tmpl": diff --git a/scrapy/commands/list.py b/scrapy/commands/list.py index 2f5032360..3b2f127c2 100644 --- a/scrapy/commands/list.py +++ b/scrapy/commands/list.py @@ -1,13 +1,21 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + from scrapy.commands import ScrapyCommand +if TYPE_CHECKING: + import argparse + class Command(ScrapyCommand): requires_project = True default_settings = {"LOG_ENABLED": False} - def short_desc(self): + def short_desc(self) -> str: return "List available spiders" - def run(self, args, opts): + def run(self, args: list[str], opts: argparse.Namespace) -> None: + assert self.crawler_process for s in sorted(self.crawler_process.spider_loader.list()): print(s) diff --git a/scrapy/commands/parse.py b/scrapy/commands/parse.py index c9f8586d3..2059dcf75 100644 --- a/scrapy/commands/parse.py +++ b/scrapy/commands/parse.py @@ -1,16 +1,19 @@ +from __future__ import annotations + +import argparse import functools import inspect import json import logging -from typing import Dict +from typing import TYPE_CHECKING, Any, TypeVar, overload from itemadapter import ItemAdapter, is_item -from twisted.internet.defer import maybeDeferred +from twisted.internet.defer import Deferred, maybeDeferred from w3lib.url import is_url from scrapy.commands import BaseRunSpiderCommand from scrapy.exceptions import UsageError -from scrapy.http import Request +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 @@ -18,26 +21,38 @@ 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 +if TYPE_CHECKING: + from collections.abc import AsyncGenerator, Coroutine, Iterable + + from twisted.python.failure import Failure + + from scrapy.http.request import CallbackT + from scrapy.spiders import Spider + + logger = logging.getLogger(__name__) +_T = TypeVar("_T") + class Command(BaseRunSpiderCommand): requires_project = True - spider = None - items: Dict[int, list] = {} - requests: Dict[int, list] = {} + spider: Spider | None = None + items: dict[int, list[Any]] = {} + requests: dict[int, list[Request]] = {} + spidercls: type[Spider] | None first_response = None - def syntax(self): + def syntax(self) -> str: return "[options] " - def short_desc(self): + def short_desc(self) -> str: return "Parse URL (using its spider) and print the results" - def add_options(self, parser): - BaseRunSpiderCommand.add_options(self, parser) + def add_options(self, parser: argparse.ArgumentParser) -> None: + super().add_options(parser) parser.add_argument( "--spider", dest="spider", @@ -106,7 +121,7 @@ class Command(BaseRunSpiderCommand): ) @property - def max_level(self): + def max_level(self) -> int: max_items, max_requests = 0, 0 if self.items: max_items = max(self.items) @@ -114,13 +129,21 @@ class Command(BaseRunSpiderCommand): max_requests = max(self.requests) return max(max_items, max_requests) - def handle_exception(self, _failure): + def handle_exception(self, _failure: Failure) -> None: logger.error( "An error is caught while iterating the async iterable", exc_info=failure_to_exc_info(_failure), ) - def iterate_spider_output(self, result): + @overload + def iterate_spider_output( + self, result: AsyncGenerator[_T] | Coroutine[Any, Any, _T] + ) -> Deferred[_T]: ... + + @overload + def iterate_spider_output(self, result: _T) -> Iterable[Any]: ... + + def iterate_spider_output(self, result: Any) -> Iterable[Any] | Deferred[Any]: if inspect.isasyncgen(result): d = deferred_from_coro( collect_asyncgen(aiter_errback(result, self.handle_exception)) @@ -133,15 +156,15 @@ class Command(BaseRunSpiderCommand): return d return arg_to_iter(deferred_from_coro(result)) - def add_items(self, lvl, new_items): + def add_items(self, lvl: int, new_items: list[Any]) -> None: old_items = self.items.get(lvl, []) self.items[lvl] = old_items + new_items - def add_requests(self, lvl, new_reqs): + def add_requests(self, lvl: int, new_reqs: list[Request]) -> None: old_reqs = self.requests.get(lvl, []) self.requests[lvl] = old_reqs + new_reqs - def print_items(self, lvl=None, colour=True): + def print_items(self, lvl: int | None = None, colour: bool = True) -> None: if lvl is None: items = [item for lst in self.items.values() for item in lst] else: @@ -150,7 +173,7 @@ class Command(BaseRunSpiderCommand): print("# Scraped Items ", "-" * 60) display.pprint([ItemAdapter(x).asdict() for x in items], colorize=colour) - def print_requests(self, lvl=None, colour=True): + def print_requests(self, lvl: int | None = None, colour: bool = True) -> None: if lvl is None: if self.requests: requests = self.requests[max(self.requests)] @@ -162,7 +185,7 @@ class Command(BaseRunSpiderCommand): print("# Requests ", "-" * 65) display.pprint(requests, colorize=colour) - def print_results(self, opts): + def print_results(self, opts: argparse.Namespace) -> None: colour = not opts.nocolour if opts.verbose: @@ -179,7 +202,14 @@ class Command(BaseRunSpiderCommand): if not opts.nolinks: self.print_requests(colour=colour) - def _get_items_and_requests(self, spider_output, opts, depth, spider, callback): + def _get_items_and_requests( + self, + spider_output: Iterable[Any], + opts: argparse.Namespace, + depth: int, + spider: Spider, + callback: CallbackT, + ) -> tuple[list[Any], list[Request], argparse.Namespace, int, Spider, CallbackT]: items, requests = [], [] for x in spider_output: if is_item(x): @@ -188,14 +218,21 @@ class Command(BaseRunSpiderCommand): requests.append(x) return items, requests, opts, depth, spider, callback - def run_callback(self, response, callback, cb_kwargs=None): + def run_callback( + self, + response: Response, + callback: CallbackT, + cb_kwargs: dict[str, Any] | None = None, + ) -> Deferred[Any]: cb_kwargs = cb_kwargs or {} d = maybeDeferred(self.iterate_spider_output, callback(response, **cb_kwargs)) return d - def get_callback_from_rules(self, spider, response): + def get_callback_from_rules( + self, spider: Spider, response: Response + ) -> CallbackT | str | None: if getattr(spider, "rules", None): - for rule in spider.rules: + for rule in spider.rules: # type: ignore[attr-defined] if rule.link_extractor.matches(response.url): return rule.callback or "parse" else: @@ -204,8 +241,10 @@ class Command(BaseRunSpiderCommand): "please specify a callback to use for parsing", {"spider": spider.name}, ) + return None - def set_spidercls(self, url, opts): + def set_spidercls(self, url: str, opts: argparse.Namespace) -> None: + assert self.crawler_process spider_loader = self.crawler_process.spider_loader if opts.spider: try: @@ -219,13 +258,15 @@ class Command(BaseRunSpiderCommand): if not self.spidercls: logger.error("Unable to find spider for: %(url)s", {"url": url}) - def _start_requests(spider): + def _start_requests(spider: Spider) -> Iterable[Request]: yield self.prepare_request(spider, Request(url), opts) if self.spidercls: - self.spidercls.start_requests = _start_requests + self.spidercls.start_requests = _start_requests # type: ignore[assignment,method-assign] - def start_parsing(self, url, opts): + def start_parsing(self, url: str, opts: argparse.Namespace) -> None: + assert self.crawler_process + assert self.spidercls self.crawler_process.crawl(self.spidercls, **opts.spargs) self.pcrawler = list(self.crawler_process.crawlers)[0] self.crawler_process.start() @@ -233,7 +274,12 @@ class Command(BaseRunSpiderCommand): if not self.first_response: logger.error("No response downloaded for: %(url)s", {"url": url}) - def scraped_data(self, args): + def scraped_data( + self, + args: tuple[ + list[Any], list[Request], argparse.Namespace, int, Spider, CallbackT + ], + ) -> list[Any]: items, requests, opts, depth, spider, callback = args if opts.pipelines: itemproc = self.pcrawler.engine.scraper.itemproc @@ -252,8 +298,14 @@ class Command(BaseRunSpiderCommand): return scraped_data - def _get_callback(self, *, spider, opts, response=None): - cb = None + def _get_callback( + self, + *, + spider: Spider, + opts: argparse.Namespace, + response: Response | None = None, + ) -> CallbackT: + cb: str | CallbackT | None = None if response: cb = response.meta["_callback"] if not cb: @@ -270,6 +322,7 @@ class Command(BaseRunSpiderCommand): cb = "parse" if not callable(cb): + assert cb is not None cb_method = getattr(spider, cb, None) if callable(cb_method): cb = cb_method @@ -277,10 +330,13 @@ class Command(BaseRunSpiderCommand): raise ValueError( f"Cannot find callback {cb!r} in spider: {spider.name}" ) + assert callable(cb) return cb - def prepare_request(self, spider, request, opts): - def callback(response, **cb_kwargs): + def prepare_request( + self, spider: Spider, request: Request, opts: argparse.Namespace + ) -> Request: + def callback(response: Response, **cb_kwargs: Any) -> Deferred[list[Any]]: # memorize first request if not self.first_response: self.first_response = response @@ -288,7 +344,7 @@ class Command(BaseRunSpiderCommand): cb = self._get_callback(spider=spider, opts=opts, response=response) # parse items and requests - depth = response.meta["_depth"] + depth: int = response.meta["_depth"] d = self.run_callback(response, cb, cb_kwargs) d.addCallback(self._get_items_and_requests, opts, depth, spider, callback) @@ -311,13 +367,13 @@ class Command(BaseRunSpiderCommand): request.callback = callback return request - def process_options(self, args, opts): - BaseRunSpiderCommand.process_options(self, args, opts) + def process_options(self, args: list[str], opts: argparse.Namespace) -> None: + super().process_options(args, opts) self.process_request_meta(opts) self.process_request_cb_kwargs(opts) - def process_request_meta(self, opts): + def process_request_meta(self, opts: argparse.Namespace) -> None: if opts.meta: try: opts.meta = json.loads(opts.meta) @@ -328,7 +384,7 @@ class Command(BaseRunSpiderCommand): print_help=False, ) - def process_request_cb_kwargs(self, opts): + def process_request_cb_kwargs(self, opts: argparse.Namespace) -> None: if opts.cbkwargs: try: opts.cbkwargs = json.loads(opts.cbkwargs) @@ -339,7 +395,7 @@ class Command(BaseRunSpiderCommand): print_help=False, ) - def run(self, args, opts): + def run(self, args: list[str], opts: argparse.Namespace) -> None: # parse arguments if not len(args) == 1 or not is_url(args[0]): raise UsageError() diff --git a/scrapy/commands/runspider.py b/scrapy/commands/runspider.py index 58ed89a81..7ec56899c 100644 --- a/scrapy/commands/runspider.py +++ b/scrapy/commands/runspider.py @@ -1,16 +1,21 @@ +from __future__ import annotations + +import argparse import sys from importlib import import_module -from os import PathLike from pathlib import Path -from types import ModuleType -from typing import Union +from typing import TYPE_CHECKING from scrapy.commands import BaseRunSpiderCommand from scrapy.exceptions import UsageError from scrapy.utils.spider import iter_spider_classes +if TYPE_CHECKING: + from os import PathLike + from types import ModuleType -def _import_file(filepath: Union[str, PathLike]) -> ModuleType: + +def _import_file(filepath: str | PathLike[str]) -> ModuleType: abspath = Path(filepath).resolve() if abspath.suffix not in (".py", ".pyw"): raise ValueError(f"Not a Python source file: {abspath}") @@ -27,16 +32,16 @@ class Command(BaseRunSpiderCommand): requires_project = False default_settings = {"SPIDER_LOADER_WARN_ONLY": True} - def syntax(self): + def syntax(self) -> str: return "[options] " - def short_desc(self): + def short_desc(self) -> str: return "Run a self-contained spider (without creating a project)" - def long_desc(self): + def long_desc(self) -> str: return "Run the spider defined in the given file" - def run(self, args, opts): + def run(self, args: list[str], opts: argparse.Namespace) -> None: if len(args) != 1: raise UsageError() filename = Path(args[0]) @@ -51,6 +56,7 @@ class Command(BaseRunSpiderCommand): raise UsageError(f"No spider found in file: {filename}\n") spidercls = spclasses.pop() + assert self.crawler_process self.crawler_process.crawl(spidercls, **opts.spargs) self.crawler_process.start() diff --git a/scrapy/commands/settings.py b/scrapy/commands/settings.py index 318187204..59f86b9a7 100644 --- a/scrapy/commands/settings.py +++ b/scrapy/commands/settings.py @@ -1,3 +1,4 @@ +import argparse import json from scrapy.commands import ScrapyCommand @@ -8,14 +9,14 @@ class Command(ScrapyCommand): requires_project = False default_settings = {"LOG_ENABLED": False, "SPIDER_LOADER_WARN_ONLY": True} - def syntax(self): + def syntax(self) -> str: return "[options]" - def short_desc(self): + def short_desc(self) -> str: return "Get settings values" - def add_options(self, parser): - ScrapyCommand.add_options(self, parser) + def add_options(self, parser: argparse.ArgumentParser) -> None: + super().add_options(parser) parser.add_argument( "--get", dest="get", metavar="SETTING", help="print raw setting value" ) @@ -44,7 +45,8 @@ class Command(ScrapyCommand): help="print setting value, interpreted as a list", ) - def run(self, args, opts): + def run(self, args: list[str], opts: argparse.Namespace) -> None: + assert self.crawler_process settings = self.crawler_process.settings if opts.get: s = settings.get(opts.get) diff --git a/scrapy/commands/shell.py b/scrapy/commands/shell.py index 12e37babc..27e6d68ee 100644 --- a/scrapy/commands/shell.py +++ b/scrapy/commands/shell.py @@ -3,9 +3,11 @@ Scrapy Shell See documentation in docs/topics/shell.rst """ -from argparse import Namespace + +from __future__ import annotations + from threading import Thread -from typing import List, Type +from typing import TYPE_CHECKING, Any from scrapy import Spider from scrapy.commands import ScrapyCommand @@ -14,6 +16,9 @@ from scrapy.shell import Shell from scrapy.utils.spider import DefaultSpider, spidercls_for_request from scrapy.utils.url import guess_scheme +if TYPE_CHECKING: + from argparse import ArgumentParser, Namespace + class Command(ScrapyCommand): requires_project = False @@ -23,20 +28,20 @@ class Command(ScrapyCommand): "DUPEFILTER_CLASS": "scrapy.dupefilters.BaseDupeFilter", } - def syntax(self): + def syntax(self) -> str: return "[url|file]" - def short_desc(self): + def short_desc(self) -> str: return "Interactive scraping console" - def long_desc(self): + def long_desc(self) -> str: return ( "Interactive console for scraping the given url or file. " "Use ./file.html syntax or full path for local file." ) - def add_options(self, parser): - ScrapyCommand.add_options(self, parser) + def add_options(self, parser: ArgumentParser) -> None: + super().add_options(parser) parser.add_argument( "-c", dest="code", @@ -51,13 +56,13 @@ class Command(ScrapyCommand): help="do not handle HTTP 3xx status codes and print response as-is", ) - def update_vars(self, vars): + def update_vars(self, vars: dict[str, Any]) -> None: """You can use this function to update the Scrapy objects that will be available in the shell """ pass - def run(self, args: List[str], opts: Namespace) -> None: + def run(self, args: list[str], opts: Namespace) -> None: url = args[0] if args else None if url: # first argument may be a local file @@ -66,7 +71,7 @@ class Command(ScrapyCommand): assert self.crawler_process spider_loader = self.crawler_process.spider_loader - spidercls: Type[Spider] = DefaultSpider + spidercls: type[Spider] = DefaultSpider if opts.spider: spidercls = spider_loader.load(opts.spider) elif url: @@ -87,7 +92,8 @@ class Command(ScrapyCommand): shell = Shell(crawler, update_vars=self.update_vars, code=opts.code) shell.start(url=url, redirect=not opts.no_redirect) - def _start_crawler_thread(self): + def _start_crawler_thread(self) -> None: + assert self.crawler_process t = Thread( target=self.crawler_process.start, kwargs={"stop_after_crawl": False, "install_signal_handlers": False}, diff --git a/scrapy/commands/startproject.py b/scrapy/commands/startproject.py index fde609c6f..f54c02369 100644 --- a/scrapy/commands/startproject.py +++ b/scrapy/commands/startproject.py @@ -1,3 +1,6 @@ +from __future__ import annotations + +import argparse import os import re import string @@ -11,7 +14,7 @@ from scrapy.commands import ScrapyCommand from scrapy.exceptions import UsageError from scrapy.utils.template import render_templatefile, string_camelcase -TEMPLATES_TO_RENDER = ( +TEMPLATES_TO_RENDER: tuple[tuple[str, ...], ...] = ( ("scrapy.cfg",), ("${project_name}", "settings.py.tmpl"), ("${project_name}", "items.py.tmpl"), @@ -22,7 +25,7 @@ TEMPLATES_TO_RENDER = ( IGNORE = ignore_patterns("*.pyc", "__pycache__", ".svn") -def _make_writable(path): +def _make_writable(path: str | os.PathLike) -> None: current_permissions = os.stat(path).st_mode os.chmod(path, current_permissions | OWNER_WRITE_PERMISSION) @@ -31,14 +34,14 @@ class Command(ScrapyCommand): requires_project = False default_settings = {"LOG_ENABLED": False, "SPIDER_LOADER_WARN_ONLY": True} - def syntax(self): + def syntax(self) -> str: return " [project_dir]" - def short_desc(self): + def short_desc(self) -> str: return "Create new project" - def _is_valid_name(self, project_name): - def _module_exists(module_name): + def _is_valid_name(self, project_name: str) -> bool: + def _module_exists(module_name: str) -> bool: spec = find_spec(module_name) return spec is not None and spec.loader is not None @@ -53,7 +56,7 @@ class Command(ScrapyCommand): return True return False - def _copytree(self, src: Path, dst: Path): + def _copytree(self, src: Path, dst: Path) -> None: """ Since the original function always creates the directory, to resolve the issue a new function had to be created. It's a simple copy and @@ -84,7 +87,7 @@ class Command(ScrapyCommand): copystat(src, dst) _make_writable(dst) - def run(self, args, opts): + def run(self, args: list[str], opts: argparse.Namespace) -> None: if len(args) not in (1, 2): raise UsageError() diff --git a/scrapy/commands/version.py b/scrapy/commands/version.py index 47582866b..571f4fda8 100644 --- a/scrapy/commands/version.py +++ b/scrapy/commands/version.py @@ -1,3 +1,5 @@ +import argparse + import scrapy from scrapy.commands import ScrapyCommand from scrapy.utils.versions import scrapy_components_versions @@ -6,14 +8,14 @@ from scrapy.utils.versions import scrapy_components_versions class Command(ScrapyCommand): default_settings = {"LOG_ENABLED": False, "SPIDER_LOADER_WARN_ONLY": True} - def syntax(self): + def syntax(self) -> str: return "[-v]" - def short_desc(self): + def short_desc(self) -> str: return "Print Scrapy version" - def add_options(self, parser): - ScrapyCommand.add_options(self, parser) + def add_options(self, parser: argparse.ArgumentParser) -> None: + super().add_options(parser) parser.add_argument( "--verbose", "-v", @@ -22,7 +24,7 @@ class Command(ScrapyCommand): help="also display twisted/python/platform info (useful for bug reports)", ) - def run(self, args, opts): + def run(self, args: list[str], opts: argparse.Namespace) -> None: if opts.verbose: versions = scrapy_components_versions() width = max(len(n) for (n, _) in versions) diff --git a/scrapy/commands/view.py b/scrapy/commands/view.py index ebdfa10a8..21679e3aa 100644 --- a/scrapy/commands/view.py +++ b/scrapy/commands/view.py @@ -1,21 +1,28 @@ import argparse +import logging from scrapy.commands import fetch +from scrapy.http import Response, TextResponse from scrapy.utils.response import open_in_browser +logger = logging.getLogger(__name__) + class Command(fetch.Command): - def short_desc(self): + def short_desc(self) -> str: return "Open URL in browser, as seen by Scrapy" - def long_desc(self): + def long_desc(self) -> str: return ( "Fetch a URL using the Scrapy downloader and show its contents in a browser" ) - def add_options(self, parser): + def add_options(self, parser: argparse.ArgumentParser) -> None: super().add_options(parser) parser.add_argument("--headers", help=argparse.SUPPRESS) - def _print_response(self, response, opts): + def _print_response(self, response: Response, opts: argparse.Namespace) -> None: + if not isinstance(response, TextResponse): + logger.error("Cannot view a non-text response.") + return open_in_browser(response) diff --git a/scrapy/contracts/__init__.py b/scrapy/contracts/__init__.py index d46eb7c51..c20c02ca6 100644 --- a/scrapy/contracts/__init__.py +++ b/scrapy/contracts/__init__.py @@ -1,32 +1,44 @@ +from __future__ import annotations + import re import sys +from collections.abc import AsyncGenerator, Iterable from functools import wraps from inspect import getmembers from types import CoroutineType -from typing import AsyncGenerator, Dict, Optional, Type -from unittest import TestCase +from typing import TYPE_CHECKING, Any, cast +from unittest import TestCase, TestResult -from scrapy.http import Request +from scrapy.http import Request, Response from scrapy.utils.python import get_spec from scrapy.utils.spider import iterate_spider_output +if TYPE_CHECKING: + from collections.abc import Callable + + from twisted.python.failure import Failure + + from scrapy import Spider + class Contract: """Abstract class for contracts""" - request_cls: Optional[Type[Request]] = None + request_cls: type[Request] | None = None + name: str - def __init__(self, method, *args): + def __init__(self, method: Callable, *args: Any): self.testcase_pre = _create_testcase(method, f"@{self.name} pre-hook") self.testcase_post = _create_testcase(method, f"@{self.name} post-hook") - self.args = args + self.args: tuple[Any, ...] = args - def add_pre_hook(self, request, results): + def add_pre_hook(self, request: Request, results: TestResult) -> Request: if hasattr(self, "pre_process"): cb = request.callback + assert cb is not None @wraps(cb) - def wrapper(response, **cb_kwargs): + def wrapper(response: Response, **cb_kwargs: Any) -> list[Any]: try: results.startTest(self.testcase_pre) self.pre_process(response) @@ -42,23 +54,24 @@ class Contract: if isinstance(cb_result, (AsyncGenerator, CoroutineType)): raise TypeError("Contracts don't support async callbacks") return list( # pylint: disable=return-in-finally - iterate_spider_output(cb_result) + cast(Iterable[Any], iterate_spider_output(cb_result)) ) request.callback = wrapper return request - def add_post_hook(self, request, results): + def add_post_hook(self, request: Request, results: TestResult) -> Request: if hasattr(self, "post_process"): cb = request.callback + assert cb is not None @wraps(cb) - def wrapper(response, **cb_kwargs): + def wrapper(response: Response, **cb_kwargs: Any) -> list[Any]: cb_result = cb(response, **cb_kwargs) if isinstance(cb_result, (AsyncGenerator, CoroutineType)): raise TypeError("Contracts don't support async callbacks") - output = list(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) @@ -76,18 +89,18 @@ class Contract: return request - def adjust_request_args(self, args): + def adjust_request_args(self, args: dict[str, Any]) -> dict[str, Any]: return args class ContractsManager: - contracts: Dict[str, Contract] = {} + contracts: dict[str, type[Contract]] = {} - def __init__(self, contracts): + def __init__(self, contracts: Iterable[type[Contract]]): for contract in contracts: self.contracts[contract.name] = contract - def tested_methods_from_spidercls(self, spidercls): + def tested_methods_from_spidercls(self, spidercls: type[Spider]) -> list[str]: is_method = re.compile(r"^\s*@", re.MULTILINE).search methods = [] for key, value in getmembers(spidercls): @@ -96,21 +109,25 @@ class ContractsManager: return methods - def extract_contracts(self, method): - contracts = [] + def extract_contracts(self, method: Callable) -> list[Contract]: + contracts: list[Contract] = [] + assert method.__doc__ is not None for line in method.__doc__.split("\n"): line = line.strip() if line.startswith("@"): - name, args = re.match(r"@(\w+)\s*(.*)", line).groups() + m = re.match(r"@(\w+)\s*(.*)", line) + if m is None: + continue + name, args = m.groups() args = re.split(r"\s+", args) contracts.append(self.contracts[name](method, *args)) return contracts - def from_spider(self, spider, results): - requests = [] + def from_spider(self, spider: Spider, results: TestResult) -> list[Request | None]: + requests: list[Request | None] = [] for method in self.tested_methods_from_spidercls(type(spider)): bound_method = spider.__getattribute__(method) try: @@ -121,7 +138,7 @@ class ContractsManager: return requests - def from_method(self, method, results): + def from_method(self, method: Callable, results: TestResult) -> Request | None: contracts = self.extract_contracts(method) if contracts: request_cls = Request @@ -154,22 +171,26 @@ class ContractsManager: self._clean_req(request, method, results) return request + return None - def _clean_req(self, request, method, results): + def _clean_req( + self, request: Request, method: Callable, results: TestResult + ) -> None: """stop the request from returning objects and records any errors""" cb = request.callback + assert cb is not None @wraps(cb) - def cb_wrapper(response, **cb_kwargs): + def cb_wrapper(response: Response, **cb_kwargs: Any) -> None: try: output = cb(response, **cb_kwargs) - output = list(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()) - def eb_wrapper(failure): + def eb_wrapper(failure: Failure) -> None: case = _create_testcase(method, "errback") exc_info = failure.type, failure.value, failure.getTracebackObject() results.addError(case, exc_info) @@ -178,11 +199,11 @@ class ContractsManager: request.errback = eb_wrapper -def _create_testcase(method, desc): - spider = method.__self__.name +def _create_testcase(method: Callable, desc: str) -> TestCase: + spider = method.__self__.name # type: ignore[attr-defined] class ContractTestCase(TestCase): - def __str__(_self): + def __str__(_self) -> str: return f"[{spider}] {method.__name__} ({desc})" name = f"{spider}_{method.__name__}" diff --git a/scrapy/contracts/default.py b/scrapy/contracts/default.py index eac702cef..6f357ba20 100644 --- a/scrapy/contracts/default.py +++ b/scrapy/contracts/default.py @@ -1,4 +1,7 @@ +from __future__ import annotations + import json +from typing import Any, Callable from itemadapter import ItemAdapter, is_item @@ -15,7 +18,7 @@ class UrlContract(Contract): name = "url" - def adjust_request_args(self, args): + def adjust_request_args(self, args: dict[str, Any]) -> dict[str, Any]: args["url"] = self.args[0] return args @@ -29,11 +32,25 @@ class CallbackKeywordArgumentsContract(Contract): name = "cb_kwargs" - def adjust_request_args(self, args): + def adjust_request_args(self, args: dict[str, Any]) -> dict[str, Any]: args["cb_kwargs"] = json.loads(" ".join(self.args)) return args +class MetadataContract(Contract): + """Contract to set metadata arguments for the request. + The value should be JSON-encoded dictionary, e.g.: + + @meta {"arg1": "some value"} + """ + + name = "meta" + + def adjust_request_args(self, args: dict[str, Any]) -> dict[str, Any]: + args["meta"] = json.loads(" ".join(self.args)) + return args + + class ReturnsContract(Contract): """Contract to check the output of a callback @@ -48,14 +65,14 @@ class ReturnsContract(Contract): """ name = "returns" - object_type_verifiers = { + object_type_verifiers: dict[str | None, Callable[[Any], bool]] = { "request": lambda x: isinstance(x, Request), "requests": lambda x: isinstance(x, Request), "item": is_item, "items": is_item, } - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any): super().__init__(*args, **kwargs) if len(self.args) not in [1, 2, 3]: @@ -66,16 +83,16 @@ class ReturnsContract(Contract): self.obj_type_verifier = self.object_type_verifiers[self.obj_name] try: - self.min_bound = int(self.args[1]) + self.min_bound: float = int(self.args[1]) except IndexError: self.min_bound = 1 try: - self.max_bound = int(self.args[2]) + self.max_bound: float = int(self.args[2]) except IndexError: self.max_bound = float("inf") - def post_process(self, output): + def post_process(self, output: list[Any]) -> None: occurrences = 0 for x in output: if self.obj_type_verifier(x): @@ -85,7 +102,7 @@ class ReturnsContract(Contract): if not assertion: if self.min_bound == self.max_bound: - expected = self.min_bound + expected = str(self.min_bound) else: expected = f"{self.min_bound}..{self.max_bound}" @@ -101,7 +118,7 @@ class ScrapesContract(Contract): name = "scrapes" - def post_process(self, output): + def post_process(self, output: list[Any]) -> None: for x in output: if is_item(x): missing = [arg for arg in self.args if arg not in ItemAdapter(x)] diff --git a/scrapy/core/downloader/__init__.py b/scrapy/core/downloader/__init__.py index c84525160..1cc0422b7 100644 --- a/scrapy/core/downloader/__init__.py +++ b/scrapy/core/downloader/__init__.py @@ -1,8 +1,11 @@ +from __future__ import annotations + import random +import warnings from collections import deque from datetime import datetime from time import time -from typing import TYPE_CHECKING, Any, Deque, Dict, Set, Tuple, cast +from typing import TYPE_CHECKING, Any, TypeVar, cast from twisted.internet import task from twisted.internet.defer import Deferred @@ -10,28 +13,40 @@ from twisted.internet.defer import Deferred from scrapy import Request, Spider, signals from scrapy.core.downloader.handlers import DownloadHandlers from scrapy.core.downloader.middleware import DownloaderMiddlewareManager -from scrapy.http import Response +from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.resolver import dnscache -from scrapy.settings import BaseSettings from scrapy.signalmanager import SignalManager from scrapy.utils.defer import mustbe_deferred from scrapy.utils.httpobj import urlparse_cached if TYPE_CHECKING: from scrapy.crawler import Crawler + from scrapy.http import Response + from scrapy.settings import BaseSettings + + +_T = TypeVar("_T") class Slot: """Downloader slot""" - def __init__(self, concurrency: int, delay: float, randomize_delay: bool): + def __init__( + self, + concurrency: int, + delay: float, + randomize_delay: bool, + *, + throttle: bool | None = None, + ): self.concurrency: int = concurrency self.delay: float = delay self.randomize_delay: bool = randomize_delay + self.throttle = throttle - self.active: Set[Request] = set() - self.queue: Deque[Tuple[Request, Deferred]] = deque() - self.transferring: Set[Request] = set() + self.active: set[Request] = set() + self.queue: deque[tuple[Request, Deferred[Response]]] = deque() + self.transferring: set[Request] = set() self.lastseen: float = 0 self.latercall = None @@ -40,7 +55,7 @@ class Slot: def download_delay(self) -> float: if self.randomize_delay: - return random.uniform(0.5 * self.delay, 1.5 * self.delay) + return random.uniform(0.5 * self.delay, 1.5 * self.delay) # nosec return self.delay def close(self) -> None: @@ -52,13 +67,15 @@ class Slot: return ( f"{cls_name}(concurrency={self.concurrency!r}, " f"delay={self.delay:.2f}, " - f"randomize_delay={self.randomize_delay!r})" + f"randomize_delay={self.randomize_delay!r}, " + f"throttle={self.throttle!r})" ) def __str__(self) -> str: return ( f"" @@ -67,7 +84,7 @@ class Slot: def _get_concurrency_delay( concurrency: int, spider: Spider, settings: BaseSettings -) -> Tuple[int, float]: +) -> tuple[int, float]: delay: float = settings.getfloat("DOWNLOAD_DELAY") if hasattr(spider, "download_delay"): delay = spider.download_delay @@ -81,11 +98,11 @@ def _get_concurrency_delay( class Downloader: DOWNLOAD_SLOT = "download_slot" - def __init__(self, crawler: "Crawler"): + def __init__(self, crawler: Crawler): self.settings: BaseSettings = crawler.settings self.signals: SignalManager = crawler.signals - self.slots: Dict[str, Slot] = {} - self.active: Set[Request] = set() + self.slots: dict[str, Slot] = {} + self.active: set[Request] = set() self.handlers: DownloadHandlers = DownloadHandlers(crawler) self.total_concurrency: int = self.settings.getint("CONCURRENT_REQUESTS") self.domain_concurrency: int = self.settings.getint( @@ -98,24 +115,26 @@ class Downloader: ) self._slot_gc_loop: task.LoopingCall = task.LoopingCall(self._slot_gc) self._slot_gc_loop.start(60) - self.per_slot_settings: Dict[str, Dict[str, Any]] = self.settings.getdict( + self.per_slot_settings: dict[str, dict[str, Any]] = self.settings.getdict( "DOWNLOAD_SLOTS", {} ) - def fetch(self, request: Request, spider: Spider) -> Deferred: - def _deactivate(response: Response) -> Response: + def fetch(self, request: Request, spider: Spider) -> Deferred[Response | Request]: + def _deactivate(response: _T) -> _T: self.active.remove(request) return response self.active.add(request) - dfd = self.middleware.download(self._enqueue_request, request, spider) + dfd: Deferred[Response | Request] = self.middleware.download( + self._enqueue_request, request, spider + ) return dfd.addBoth(_deactivate) def needs_backout(self) -> bool: return len(self.active) >= self.total_concurrency - def _get_slot(self, request: Request, spider: Spider) -> Tuple[str, Slot]: - key = self._get_slot_key(request, spider) + def _get_slot(self, request: Request, spider: Spider) -> tuple[str, Slot]: + key = self.get_slot_key(request) if key not in self.slots: slot_settings = self.per_slot_settings.get(key, {}) conc = ( @@ -127,12 +146,13 @@ class Downloader: slot_settings.get("delay", delay), ) randomize_delay = slot_settings.get("randomize_delay", self.randomize_delay) - new_slot = Slot(conc, delay, randomize_delay) + throttle = slot_settings.get("throttle", None) + new_slot = Slot(conc, delay, randomize_delay, throttle=throttle) self.slots[key] = new_slot return key, self.slots[key] - def _get_slot_key(self, request: Request, spider: Spider) -> str: + def get_slot_key(self, request: Request) -> str: if self.DOWNLOAD_SLOT in request.meta: return cast(str, request.meta[self.DOWNLOAD_SLOT]) @@ -142,7 +162,15 @@ class Downloader: return key - def _enqueue_request(self, request: Request, spider: Spider) -> Deferred: + 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) + + def _enqueue_request(self, request: Request, spider: Spider) -> Deferred[Response]: key, slot = self._get_slot(request, spider) request.meta[self.DOWNLOAD_SLOT] = key @@ -154,7 +182,7 @@ class Downloader: self.signals.send_catch_log( signal=signals.request_reached_downloader, request=request, spider=spider ) - deferred: Deferred = Deferred().addBoth(_deactivate) + deferred: Deferred[Response] = Deferred().addBoth(_deactivate) slot.queue.append((request, deferred)) self._process_queue(spider, slot) return deferred @@ -187,11 +215,15 @@ class Downloader: self._process_queue(spider, slot) break - def _download(self, slot: Slot, request: Request, spider: Spider) -> Deferred: + def _download( + self, slot: Slot, request: Request, spider: Spider + ) -> Deferred[Response]: # The order is very important for the following deferreds. Do not change! # 1. Create the download deferred - dfd = mustbe_deferred(self.handlers.download_request, request, spider) + dfd: Deferred[Response] = mustbe_deferred( + self.handlers.download_request, request, spider + ) # 2. Notify response_downloaded listeners about the recent download # before querying queue for next request @@ -212,7 +244,7 @@ class Downloader: # middleware itself) slot.transferring.add(request) - def finish_transferring(_: Any) -> Any: + def finish_transferring(_: _T) -> _T: slot.transferring.remove(request) self._process_queue(spider, slot) self.signals.send_catch_log( diff --git a/scrapy/core/downloader/contextfactory.py b/scrapy/core/downloader/contextfactory.py index dba4d8cdc..f80f832a7 100644 --- a/scrapy/core/downloader/contextfactory.py +++ b/scrapy/core/downloader/contextfactory.py @@ -1,5 +1,7 @@ +from __future__ import annotations + import warnings -from typing import TYPE_CHECKING, Any, List, Optional +from typing import TYPE_CHECKING, Any from OpenSSL import SSL from twisted.internet._sslverify import _setAcceptableProtocols @@ -19,12 +21,17 @@ from scrapy.core.downloader.tls import ( ScrapyClientTLSOptions, openssl_methods, ) -from scrapy.settings import BaseSettings from scrapy.utils.misc import build_from_crawler, load_object if TYPE_CHECKING: from twisted.internet._sslverify import ClientTLSOptions + # typing.Self requires Python 3.11 + from typing_extensions import Self + + from scrapy.crawler import Crawler + from scrapy.settings import BaseSettings + @implementer(IPolicyForHTTPS) class ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): @@ -42,7 +49,7 @@ class ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): self, method: int = SSL.SSLv23_METHOD, tls_verbose_logging: bool = False, - tls_ciphers: Optional[str] = None, + tls_ciphers: str | None = None, *args: Any, **kwargs: Any, ): @@ -62,11 +69,11 @@ class ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): method: int = SSL.SSLv23_METHOD, *args: Any, **kwargs: Any, - ): + ) -> Self: tls_verbose_logging: bool = settings.getbool( "DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING" ) - tls_ciphers: Optional[str] = settings["DOWNLOADER_CLIENT_TLS_CIPHERS"] + tls_ciphers: str | None = settings["DOWNLOADER_CLIENT_TLS_CIPHERS"] return cls( # type: ignore[misc] method=method, tls_verbose_logging=tls_verbose_logging, @@ -97,11 +104,11 @@ class ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): # kept for old-style HTTP/1.0 downloader context twisted calls, # e.g. connectSSL() def getContext(self, hostname: Any = None, port: Any = None) -> SSL.Context: - ctx = self.getCertificateOptions().getContext() + ctx: SSL.Context = self.getCertificateOptions().getContext() ctx.set_options(0x4) # OP_LEGACY_SERVER_CONNECT return ctx - def creatorForNetloc(self, hostname: bytes, port: int) -> "ClientTLSOptions": + def creatorForNetloc(self, hostname: bytes, port: int) -> ClientTLSOptions: return ScrapyClientTLSOptions( hostname.decode("ascii"), self.getContext(), @@ -128,7 +135,7 @@ class BrowserLikeContextFactory(ScrapyClientContextFactory): ``SSLv23_METHOD``) which allows TLS protocol negotiation. """ - def creatorForNetloc(self, hostname: bytes, port: int) -> "ClientTLSOptions": + def creatorForNetloc(self, hostname: bytes, port: int) -> ClientTLSOptions: # trustRoot set to platformTrust() will use the platform's root CAs. # # This means that a website like https://www.cacert.org will be rejected @@ -147,20 +154,22 @@ class AcceptableProtocolsContextFactory: negotiation. """ - def __init__(self, context_factory: Any, acceptable_protocols: List[bytes]): + def __init__(self, context_factory: Any, acceptable_protocols: list[bytes]): verifyObject(IPolicyForHTTPS, context_factory) self._wrapped_context_factory: Any = context_factory - self._acceptable_protocols: List[bytes] = acceptable_protocols + self._acceptable_protocols: list[bytes] = acceptable_protocols - def creatorForNetloc(self, hostname: bytes, port: int) -> "ClientTLSOptions": - options: "ClientTLSOptions" = self._wrapped_context_factory.creatorForNetloc( + def creatorForNetloc(self, hostname: bytes, port: int) -> ClientTLSOptions: + options: ClientTLSOptions = self._wrapped_context_factory.creatorForNetloc( hostname, port ) _setAcceptableProtocols(options._ctx, self._acceptable_protocols) return options -def load_context_factory_from_settings(settings, crawler): +def load_context_factory_from_settings( + settings: BaseSettings, crawler: Crawler +) -> IPolicyForHTTPS: ssl_method = openssl_methods[settings.get("DOWNLOADER_CLIENT_TLS_METHOD")] context_factory_cls = load_object(settings["DOWNLOADER_CLIENTCONTEXTFACTORY"]) # try method-aware context factory diff --git a/scrapy/core/downloader/handlers/__init__.py b/scrapy/core/downloader/handlers/__init__.py index 416669b7f..218f44bbb 100644 --- a/scrapy/core/downloader/handlers/__init__.py +++ b/scrapy/core/downloader/handlers/__init__.py @@ -1,10 +1,12 @@ """Download handlers for different schemes""" +from __future__ import annotations + import logging -from typing import TYPE_CHECKING, Any, Callable, Dict, Generator, Union, cast +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, Protocol, cast from twisted.internet import defer -from twisted.internet.defer import Deferred from scrapy import Request, Spider, signals from scrapy.exceptions import NotConfigured, NotSupported @@ -13,21 +15,38 @@ 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 Generator + + from twisted.internet.defer import Deferred + from scrapy.crawler import Crawler + from scrapy.http import Response + logger = logging.getLogger(__name__) +class DownloadHandlerProtocol(Protocol): + def download_request( + self, request: Request, spider: Spider + ) -> Deferred[Response]: ... + + class DownloadHandlers: - def __init__(self, crawler: "Crawler"): - self._crawler: "Crawler" = crawler - self._schemes: Dict[ - str, Union[str, Callable] - ] = {} # stores acceptable schemes on instancing - self._handlers: Dict[str, Any] = {} # stores instanced handlers for schemes - self._notconfigured: Dict[str, str] = {} # remembers failed handlers - handlers: Dict[str, Union[str, Callable]] = without_none_values( - crawler.settings.getwithbase("DOWNLOAD_HANDLERS") + def __init__(self, crawler: Crawler): + self._crawler: Crawler = crawler + self._schemes: dict[str, str | Callable[..., Any]] = ( + {} + ) # stores acceptable schemes on instancing + self._handlers: dict[str, DownloadHandlerProtocol] = ( + {} + ) # stores instanced handlers for schemes + self._notconfigured: dict[str, str] = {} # remembers failed handlers + handlers: dict[str, str | Callable[..., Any]] = without_none_values( + cast( + "dict[str, str | Callable[..., Any]]", + crawler.settings.getwithbase("DOWNLOAD_HANDLERS"), + ) ) for scheme, clspath in handlers.items(): self._schemes[scheme] = clspath @@ -35,7 +54,7 @@ class DownloadHandlers: crawler.signals.connect(self._close, signals.engine_stopped) - def _get_handler(self, scheme: str) -> Any: + def _get_handler(self, scheme: str) -> DownloadHandlerProtocol | None: """Lazy-load the downloadhandler for a scheme only on the first request for that scheme. """ @@ -49,10 +68,12 @@ class DownloadHandlers: return self._load_handler(scheme) - def _load_handler(self, scheme: str, skip_lazy: bool = False) -> Any: + def _load_handler( + self, scheme: str, skip_lazy: bool = False + ) -> DownloadHandlerProtocol | None: path = self._schemes[scheme] try: - dhcls = load_object(path) + dhcls: type[DownloadHandlerProtocol] = load_object(path) if skip_lazy and getattr(dhcls, "lazy", True): return None dh = build_from_crawler( @@ -75,17 +96,17 @@ class DownloadHandlers: self._handlers[scheme] = dh return dh - def download_request(self, request: Request, spider: Spider) -> Deferred: + def download_request(self, request: Request, spider: Spider) -> 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 cast(Deferred, handler.download_request(request, spider)) + return handler.download_request(request, spider) @defer.inlineCallbacks - def _close(self, *_a: Any, **_kw: Any) -> Generator[Deferred, Any, None]: + def _close(self, *_a: Any, **_kw: Any) -> Generator[Deferred[Any], Any, None]: for dh in self._handlers.values(): if hasattr(dh, "close"): yield dh.close() diff --git a/scrapy/core/downloader/handlers/datauri.py b/scrapy/core/downloader/handlers/datauri.py index a7ae56a85..b3f286d87 100644 --- a/scrapy/core/downloader/handlers/datauri.py +++ b/scrapy/core/downloader/handlers/datauri.py @@ -1,12 +1,16 @@ -from typing import Any, Dict +from __future__ import annotations + +from typing import TYPE_CHECKING, Any from w3lib.url import parse_data_uri -from scrapy import Request, Spider from scrapy.http import Response, TextResponse from scrapy.responsetypes import responsetypes from scrapy.utils.decorators import defers +if TYPE_CHECKING: + from scrapy import Request, Spider + class DataURIDownloadHandler: lazy = False @@ -16,7 +20,7 @@ class DataURIDownloadHandler: uri = parse_data_uri(request.url) respcls = responsetypes.from_mimetype(uri.media_type) - resp_kwargs: Dict[str, Any] = {} + resp_kwargs: dict[str, Any] = {} if issubclass(respcls, TextResponse) and uri.media_type.split("/")[0] == "text": charset = uri.media_type_parameters.get("charset") resp_kwargs["encoding"] = charset diff --git a/scrapy/core/downloader/handlers/file.py b/scrapy/core/downloader/handlers/file.py index 4824167da..d55c516f0 100644 --- a/scrapy/core/downloader/handlers/file.py +++ b/scrapy/core/downloader/handlers/file.py @@ -1,16 +1,23 @@ +from __future__ import annotations + from pathlib import Path +from typing import TYPE_CHECKING from w3lib.url import file_uri_to_path from scrapy.responsetypes import responsetypes from scrapy.utils.decorators import defers +if TYPE_CHECKING: + from scrapy import Request, Spider + from scrapy.http import Response + class FileDownloadHandler: lazy = False @defers - def download_request(self, request, spider): + def download_request(self, request: Request, spider: Spider) -> Response: filepath = file_uri_to_path(request.url) body = Path(filepath).read_bytes() respcls = responsetypes.from_args(filename=filepath, body=body) diff --git a/scrapy/core/downloader/handlers/ftp.py b/scrapy/core/downloader/handlers/ftp.py index 4081545ce..70a769771 100644 --- a/scrapy/core/downloader/handlers/ftp.py +++ b/scrapy/core/downloader/handlers/ftp.py @@ -28,8 +28,11 @@ In case of status 200 request, response.headers will come with two keys: 'Size' - with size of the downloaded data """ +from __future__ import annotations + import re from io import BytesIO +from typing import TYPE_CHECKING, Any, BinaryIO from urllib.parse import unquote from twisted.internet.protocol import ClientCreator, Protocol @@ -40,22 +43,33 @@ from scrapy.responsetypes import responsetypes from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.python import to_bytes +if TYPE_CHECKING: + from twisted.internet.defer import Deferred + from twisted.python.failure import Failure + + # typing.Self requires Python 3.11 + from typing_extensions import Self + + from scrapy import Request, Spider + from scrapy.crawler import Crawler + from scrapy.settings import BaseSettings + class ReceivedDataProtocol(Protocol): - def __init__(self, filename=None): - self.__filename = filename - self.body = open(filename, "wb") if filename else BytesIO() - self.size = 0 + def __init__(self, filename: str | None = None): + self.__filename: str | None = filename + self.body: BinaryIO = open(filename, "wb") if filename else BytesIO() + self.size: int = 0 - def dataReceived(self, data): + def dataReceived(self, data: bytes) -> None: self.body.write(data) self.size += len(data) @property - def filename(self): + def filename(self) -> str | None: return self.__filename - def close(self): + def close(self) -> None: self.body.close() if self.filename else self.body.seek(0) @@ -65,21 +79,21 @@ _CODE_RE = re.compile(r"\d+") class FTPDownloadHandler: lazy = False - CODE_MAPPING = { + CODE_MAPPING: dict[str, int] = { "550": 404, "default": 503, } - def __init__(self, settings): + def __init__(self, settings: BaseSettings): self.default_user = settings["FTP_USER"] self.default_password = settings["FTP_PASSWORD"] self.passive_mode = settings["FTP_PASSIVE_MODE"] @classmethod - def from_crawler(cls, crawler): + def from_crawler(cls, crawler: Crawler) -> Self: return cls(crawler.settings) - def download_request(self, request, spider): + def download_request(self, request: Request, spider: Spider) -> Deferred[Response]: from twisted.internet import reactor parsed_url = urlparse_cached(request) @@ -91,28 +105,33 @@ class FTPDownloadHandler: creator = ClientCreator( reactor, FTPClient, user, password, passive=passive_mode ) - dfd = creator.connectTCP(parsed_url.hostname, parsed_url.port or 21) + dfd: Deferred[FTPClient] = creator.connectTCP( + parsed_url.hostname, parsed_url.port or 21 + ) return dfd.addCallback(self.gotClient, request, unquote(parsed_url.path)) - def gotClient(self, client, request, filepath): + def gotClient( + self, client: FTPClient, request: Request, filepath: str + ) -> Deferred[Response]: self.client = client protocol = ReceivedDataProtocol(request.meta.get("ftp_local_filename")) - return client.retrieveFile(filepath, protocol).addCallbacks( - callback=self._build_response, - callbackArgs=(request, protocol), - errback=self._failed, - errbackArgs=(request,), - ) + d = client.retrieveFile(filepath, protocol) + d.addCallback(self._build_response, request, protocol) + d.addErrback(self._failed, request) + return d - def _build_response(self, result, request, protocol): + def _build_response( + self, result: Any, request: Request, protocol: ReceivedDataProtocol + ) -> Response: self.result = result protocol.close() headers = {"local filename": protocol.filename or "", "size": protocol.size} body = to_bytes(protocol.filename or protocol.body.read()) respcls = responsetypes.from_args(url=request.url, body=body) - return respcls(url=request.url, status=200, body=body, headers=headers) + # hints for Headers-related types may need to be fixed to not use AnyStr + return respcls(url=request.url, status=200, body=body, headers=headers) # type: ignore[arg-type] - def _failed(self, result, request): + def _failed(self, result: Failure, request: Request) -> Response: message = result.getErrorMessage() if result.type == CommandFailed: m = _CODE_RE.search(message) @@ -122,4 +141,5 @@ class FTPDownloadHandler: return Response( url=request.url, status=httpcode, body=to_bytes(message) ) + assert result.type raise result.type(result.value) diff --git a/scrapy/core/downloader/handlers/http10.py b/scrapy/core/downloader/handlers/http10.py index b6ac7a251..58f7ad577 100644 --- a/scrapy/core/downloader/handlers/http10.py +++ b/scrapy/core/downloader/handlers/http10.py @@ -1,31 +1,51 @@ -"""Download handlers for http and https schemes -""" +"""Download handlers for http and https schemes""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + from scrapy.utils.misc import build_from_crawler, load_object from scrapy.utils.python import to_unicode +if TYPE_CHECKING: + from twisted.internet.defer import Deferred + from twisted.internet.interfaces import IConnector + + # typing.Self requires Python 3.11 + from typing_extensions import Self + + from scrapy import Request, Spider + from scrapy.core.downloader.contextfactory import ScrapyClientContextFactory + from scrapy.core.downloader.webclient import ScrapyHTTPClientFactory + from scrapy.crawler import Crawler + from scrapy.http import Response + from scrapy.settings import BaseSettings + class HTTP10DownloadHandler: lazy = False - def __init__(self, settings, crawler=None): - self.HTTPClientFactory = load_object(settings["DOWNLOADER_HTTPCLIENTFACTORY"]) - self.ClientContextFactory = load_object( + def __init__(self, settings: BaseSettings, crawler: Crawler): + self.HTTPClientFactory: type[ScrapyHTTPClientFactory] = load_object( + settings["DOWNLOADER_HTTPCLIENTFACTORY"] + ) + self.ClientContextFactory: type[ScrapyClientContextFactory] = load_object( settings["DOWNLOADER_CLIENTCONTEXTFACTORY"] ) - self._settings = settings - self._crawler = crawler + self._settings: BaseSettings = settings + self._crawler: Crawler = crawler @classmethod - def from_crawler(cls, crawler): + def from_crawler(cls, crawler: Crawler) -> Self: return cls(crawler.settings, crawler) - def download_request(self, request, spider): + def download_request(self, request: Request, spider: Spider) -> Deferred[Response]: """Return a deferred for the HTTP download""" factory = self.HTTPClientFactory(request) self._connect(factory) return factory.deferred - def _connect(self, factory): + def _connect(self, factory: ScrapyHTTPClientFactory) -> IConnector: from twisted.internet import reactor host, port = to_unicode(factory.host), factory.port diff --git a/scrapy/core/downloader/handlers/http11.py b/scrapy/core/downloader/handlers/http11.py index c3704de3d..bd3200e9f 100644 --- a/scrapy/core/downloader/handlers/http11.py +++ b/scrapy/core/downloader/handlers/http11.py @@ -1,65 +1,90 @@ """Download handlers for http and https schemes""" +from __future__ import annotations + import ipaddress import logging import re from contextlib import suppress from io import BytesIO from time import time +from typing import TYPE_CHECKING, Any, TypedDict, TypeVar from urllib.parse import urldefrag, urlunparse -from twisted.internet import defer, protocol, ssl +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.protocol import Factory, Protocol, connectionDone from twisted.python.failure import Failure -from twisted.web.client import ( - URI, - Agent, - HTTPConnectionPool, - ResponseDone, - ResponseFailed, -) +from twisted.web.client import URI, Agent, HTTPConnectionPool +from twisted.web.client import Response as TxResponse +from twisted.web.client import ResponseDone, ResponseFailed from twisted.web.http import PotentialDataLoss, _DataLoss from twisted.web.http_headers import Headers as TxHeaders -from twisted.web.iweb import UNKNOWN_LENGTH, IBodyProducer +from twisted.web.iweb import UNKNOWN_LENGTH, IBodyProducer, IPolicyForHTTPS from zope.interface import implementer -from scrapy import signals +from scrapy import Request, Spider, signals from scrapy.core.downloader.contextfactory import load_context_factory_from_settings from scrapy.core.downloader.webclient import _parse from scrapy.exceptions import StopDownload -from scrapy.http import Headers +from scrapy.http import Headers, Response from scrapy.responsetypes import responsetypes from scrapy.utils.python import to_bytes, to_unicode +if TYPE_CHECKING: + from twisted.internet.base import ReactorBase + from twisted.internet.interfaces import IConsumer + + # typing.NotRequired and typing.Self require Python 3.11 + from typing_extensions import NotRequired, Self + + from scrapy.crawler import Crawler + from scrapy.settings import BaseSettings + + logger = logging.getLogger(__name__) +_T = TypeVar("_T") + + +class _ResultT(TypedDict): + txresponse: TxResponse + body: bytes + flags: list[str] | None + certificate: ssl.Certificate | None + ip_address: ipaddress.IPv4Address | ipaddress.IPv6Address | None + failure: NotRequired[Failure | None] + class HTTP11DownloadHandler: lazy = False - def __init__(self, settings, crawler=None): + def __init__(self, settings: BaseSettings, crawler: Crawler): self._crawler = crawler from twisted.internet import reactor - self._pool = HTTPConnectionPool(reactor, persistent=True) + self._pool: HTTPConnectionPool = HTTPConnectionPool(reactor, persistent=True) self._pool.maxPersistentPerHost = settings.getint( "CONCURRENT_REQUESTS_PER_DOMAIN" ) self._pool._factory.noisy = False - self._contextFactory = load_context_factory_from_settings(settings, crawler) - self._default_maxsize = settings.getint("DOWNLOAD_MAXSIZE") - self._default_warnsize = settings.getint("DOWNLOAD_WARNSIZE") - self._fail_on_dataloss = settings.getbool("DOWNLOAD_FAIL_ON_DATALOSS") - self._disconnect_timeout = 1 + self._contextFactory: IPolicyForHTTPS = load_context_factory_from_settings( + settings, crawler + ) + self._default_maxsize: int = settings.getint("DOWNLOAD_MAXSIZE") + self._default_warnsize: int = settings.getint("DOWNLOAD_WARNSIZE") + self._fail_on_dataloss: bool = settings.getbool("DOWNLOAD_FAIL_ON_DATALOSS") + self._disconnect_timeout: int = 1 @classmethod - def from_crawler(cls, crawler): + def from_crawler(cls, crawler: Crawler) -> Self: return cls(crawler.settings, crawler) - def download_request(self, request, spider): + def download_request(self, request: Request, spider: Spider) -> Deferred[Response]: """Return a deferred for the HTTP download""" agent = ScrapyAgent( contextFactory=self._contextFactory, @@ -71,10 +96,10 @@ class HTTP11DownloadHandler: ) return agent.download_request(request) - def close(self): + def close(self) -> Deferred[None]: from twisted.internet import reactor - d = self._pool.closeCachedConnections() + d: Deferred[None] = self._pool.closeCachedConnections() # closeCachedConnections will hang on network or server issues, so # we'll manually timeout the deferred. # @@ -85,7 +110,7 @@ class HTTP11DownloadHandler: # issue a callback after `_disconnect_timeout` seconds. delayed_call = reactor.callLater(self._disconnect_timeout, d.callback, []) - def cancel_delayed_call(result): + def cancel_delayed_call(result: _T) -> _T: if delayed_call.active(): delayed_call.cancel() return result @@ -115,39 +140,41 @@ class TunnelingTCP4ClientEndpoint(TCP4ClientEndpoint): def __init__( self, - reactor, - host, - port, - proxyConf, - contextFactory, - timeout=30, - bindAddress=None, + reactor: ReactorBase, + host: str, + port: int, + proxyConf: tuple[str, int, bytes | None], + contextFactory: IPolicyForHTTPS, + timeout: float = 30, + bindAddress: tuple[str, int] | None = None, ): proxyHost, proxyPort, self._proxyAuthHeader = proxyConf super().__init__(reactor, proxyHost, proxyPort, timeout, bindAddress) - self._tunnelReadyDeferred = defer.Deferred() - self._tunneledHost = host - self._tunneledPort = port - self._contextFactory = contextFactory - self._connectBuffer = bytearray() + self._tunnelReadyDeferred: Deferred[Protocol] = Deferred() + self._tunneledHost: str = host + self._tunneledPort: int = port + self._contextFactory: IPolicyForHTTPS = contextFactory + self._connectBuffer: bytearray = bytearray() - def requestTunnel(self, protocol): + def requestTunnel(self, protocol: Protocol) -> Protocol: """Asks the proxy to open a tunnel.""" + assert protocol.transport tunnelReq = tunnel_request_data( self._tunneledHost, self._tunneledPort, self._proxyAuthHeader ) protocol.transport.write(tunnelReq) self._protocolDataReceived = protocol.dataReceived - protocol.dataReceived = self.processProxyResponse + protocol.dataReceived = self.processProxyResponse # type: ignore[method-assign] self._protocol = protocol return protocol - def processProxyResponse(self, rcvd_bytes): + def processProxyResponse(self, data: bytes) -> None: """Processes the response from the proxy. If the tunnel is successfully created, notifies the client that we are ready to send requests. If not raises a TunnelError. """ - self._connectBuffer += rcvd_bytes + assert self._protocol.transport + self._connectBuffer += data # make sure that enough (all) bytes are consumed # and that we've got all HTTP headers (ending with a blank line) # from the proxy so that we don't send those bytes to the TLS layer @@ -155,23 +182,24 @@ class TunnelingTCP4ClientEndpoint(TCP4ClientEndpoint): # see https://github.com/scrapy/scrapy/issues/2491 if b"\r\n\r\n" not in self._connectBuffer: return - self._protocol.dataReceived = self._protocolDataReceived + self._protocol.dataReceived = self._protocolDataReceived # type: ignore[method-assign] respm = TunnelingTCP4ClientEndpoint._responseMatcher.match(self._connectBuffer) if respm and int(respm.group("status")) == 200: # set proper Server Name Indication extension - sslOptions = self._contextFactory.creatorForNetloc( + sslOptions = self._contextFactory.creatorForNetloc( # type: ignore[call-arg,misc] self._tunneledHost, self._tunneledPort ) self._protocol.transport.startTLS(sslOptions, self._protocolFactory) self._tunnelReadyDeferred.callback(self._protocol) else: + extra: Any if respm: extra = { "status": int(respm.group("status")), "reason": respm.group("reason").strip(), } else: - extra = rcvd_bytes[: self._truncatedLength] + extra = data[: self._truncatedLength] self._tunnelReadyDeferred.errback( TunnelError( "Could not open CONNECT tunnel with proxy " @@ -179,11 +207,11 @@ class TunnelingTCP4ClientEndpoint(TCP4ClientEndpoint): ) ) - def connectFailed(self, reason): + def connectFailed(self, reason: Failure) -> None: """Propagates the errback to the appropriate deferred.""" self._tunnelReadyDeferred.errback(reason) - def connect(self, protocolFactory): + def connect(self, protocolFactory: Factory) -> Deferred[Protocol]: self._protocolFactory = protocolFactory connectDeferred = super().connect(protocolFactory) connectDeferred.addCallback(self.requestTunnel) @@ -191,7 +219,9 @@ class TunnelingTCP4ClientEndpoint(TCP4ClientEndpoint): return self._tunnelReadyDeferred -def tunnel_request_data(host, port, proxy_auth_header=None): +def tunnel_request_data( + host: str, port: int, proxy_auth_header: bytes | None = None +) -> bytes: r""" Return binary content of a CONNECT request. @@ -222,18 +252,19 @@ class TunnelingAgent(Agent): def __init__( self, - reactor, - proxyConf, - contextFactory=None, - connectTimeout=None, - bindAddress=None, - pool=None, + *, + reactor: ReactorBase, + proxyConf: tuple[str, int, bytes | None], + contextFactory: IPolicyForHTTPS, + connectTimeout: float | None = None, + bindAddress: bytes | None = None, + pool: HTTPConnectionPool | None = None, ): super().__init__(reactor, contextFactory, connectTimeout, bindAddress, pool) - self._proxyConf = proxyConf - self._contextFactory = contextFactory + self._proxyConf: tuple[str, int, bytes | None] = proxyConf + self._contextFactory: IPolicyForHTTPS = contextFactory - def _getEndpoint(self, uri): + def _getEndpoint(self, uri: URI) -> TunnelingTCP4ClientEndpoint: return TunnelingTCP4ClientEndpoint( reactor=self._reactor, host=uri.host, @@ -245,8 +276,15 @@ class TunnelingAgent(Agent): ) def _requestWithEndpoint( - self, key, endpoint, method, parsedURI, headers, bodyProducer, requestPath - ): + self, + key: Any, + endpoint: TCP4ClientEndpoint, + method: bytes, + parsedURI: bytes, + headers: TxHeaders | None, + bodyProducer: IBodyProducer | None, + requestPath: bytes, + ) -> Deferred[TxResponse]: # proxy host and port are required for HTTP pool `key` # otherwise, same remote host connection request could reuse # a cached tunneled connection to a different proxy @@ -264,7 +302,12 @@ class TunnelingAgent(Agent): class ScrapyProxyAgent(Agent): def __init__( - self, reactor, proxyURI, connectTimeout=None, bindAddress=None, pool=None + self, + reactor: ReactorBase, + proxyURI: bytes, + connectTimeout: float | None = None, + bindAddress: bytes | None = None, + pool: HTTPConnectionPool | None = None, ): super().__init__( reactor=reactor, @@ -272,9 +315,15 @@ class ScrapyProxyAgent(Agent): bindAddress=bindAddress, pool=pool, ) - self._proxyURI = URI.fromBytes(proxyURI) + self._proxyURI: URI = URI.fromBytes(proxyURI) - def request(self, method, uri, headers=None, bodyProducer=None): + def request( + self, + method: bytes, + uri: bytes, + headers: TxHeaders | None = None, + bodyProducer: IBodyProducer | None = None, + ) -> Deferred[TxResponse]: """ Issue a new request via the configured proxy. """ @@ -298,26 +347,27 @@ class ScrapyAgent: def __init__( self, - contextFactory=None, - connectTimeout=10, - bindAddress=None, - pool=None, - maxsize=0, - warnsize=0, - fail_on_dataloss=True, - crawler=None, + *, + contextFactory: IPolicyForHTTPS, + connectTimeout: float = 10, + bindAddress: bytes | None = None, + pool: HTTPConnectionPool | None = None, + maxsize: int = 0, + warnsize: int = 0, + fail_on_dataloss: bool = True, + crawler: Crawler, ): - self._contextFactory = contextFactory - self._connectTimeout = connectTimeout - self._bindAddress = bindAddress - self._pool = pool - self._maxsize = maxsize - self._warnsize = warnsize - self._fail_on_dataloss = fail_on_dataloss - self._txresponse = None - self._crawler = crawler + self._contextFactory: IPolicyForHTTPS = contextFactory + self._connectTimeout: float = connectTimeout + self._bindAddress: bytes | None = bindAddress + self._pool: HTTPConnectionPool | None = pool + self._maxsize: int = maxsize + self._warnsize: int = warnsize + self._fail_on_dataloss: bool = fail_on_dataloss + self._txresponse: TxResponse | None = None + self._crawler: Crawler = crawler - def _get_agent(self, request, timeout): + def _get_agent(self, request: Request, timeout: float) -> Agent: from twisted.internet import reactor bindaddress = request.meta.get("bindaddress") or self._bindAddress @@ -325,10 +375,10 @@ class ScrapyAgent: if proxy: proxyScheme, proxyNetloc, proxyHost, proxyPort, proxyParams = _parse(proxy) scheme = _parse(request.url)[0] - proxyHost = to_unicode(proxyHost) + proxyHost_str = to_unicode(proxyHost) if scheme == b"https": proxyAuth = request.headers.get(b"Proxy-Authorization", None) - proxyConf = (proxyHost, proxyPort, proxyAuth) + proxyConf = (proxyHost_str, proxyPort, proxyAuth) return self._TunnelingAgent( reactor=reactor, proxyConf=proxyConf, @@ -338,7 +388,9 @@ class ScrapyAgent: pool=self._pool, ) proxyScheme = proxyScheme or b"http" - proxyURI = urlunparse((proxyScheme, proxyNetloc, proxyParams, "", "", "")) + proxyURI = urlunparse( + (proxyScheme, proxyNetloc, proxyParams, b"", b"", b"") + ) return self._ProxyAgent( reactor=reactor, proxyURI=to_bytes(proxyURI, encoding="ascii"), @@ -355,7 +407,7 @@ class ScrapyAgent: pool=self._pool, ) - def download_request(self, request): + def download_request(self, request: Request) -> Deferred[Response]: from twisted.internet import reactor timeout = request.meta.get("download_timeout") or self._connectTimeout @@ -372,20 +424,20 @@ class ScrapyAgent: else: bodyproducer = None start_time = time() - d = agent.request( + d: Deferred[TxResponse] = agent.request( method, to_bytes(url, encoding="ascii"), headers, bodyproducer ) # set download latency d.addCallback(self._cb_latency, request, start_time) # response body is ready to be consumed - d.addCallback(self._cb_bodyready, request) - d.addCallback(self._cb_bodydone, request, url) + d2: Deferred[_ResultT] = d.addCallback(self._cb_bodyready, request) + d3: Deferred[Response] = d2.addCallback(self._cb_bodydone, request, url) # check download timeout - self._timeout_cl = reactor.callLater(timeout, d.cancel) - d.addBoth(self._cb_timeout, request, url, timeout) - return d + self._timeout_cl = reactor.callLater(timeout, d3.cancel) + d3.addBoth(self._cb_timeout, request, url, timeout) + return d3 - def _cb_timeout(self, result, request, url, timeout): + def _cb_timeout(self, result: _T, request: Request, url: str, timeout: float) -> _T: if self._timeout_cl.active(): self._timeout_cl.cancel() return result @@ -396,19 +448,21 @@ class ScrapyAgent: raise TimeoutError(f"Getting {url} took longer than {timeout} seconds.") - def _cb_latency(self, result, request, start_time): + def _cb_latency(self, result: _T, request: Request, start_time: float) -> _T: request.meta["download_latency"] = time() - start_time return result @staticmethod - def _headers_from_twisted_response(response): + def _headers_from_twisted_response(response: TxResponse) -> Headers: headers = Headers() if response.length != UNKNOWN_LENGTH: headers[b"Content-Length"] = str(response.length).encode() headers.update(response.headers.getAllRawHeaders()) return headers - def _cb_bodyready(self, txresponse, request): + def _cb_bodyready( + self, txresponse: TxResponse, request: Request + ) -> _ResultT | Deferred[_ResultT]: headers_received_result = self._crawler.signals.send_catch_log( signal=signals.headers_received, headers=self._headers_from_twisted_response(txresponse), @@ -464,7 +518,7 @@ class ScrapyAgent: logger.warning(warning_msg, warning_args) txresponse._transport.loseConnection() - raise defer.CancelledError(warning_msg % warning_args) + raise CancelledError(warning_msg % warning_args) if warnsize and expected_size > warnsize: logger.warning( @@ -473,11 +527,11 @@ class ScrapyAgent: {"size": expected_size, "warnsize": warnsize, "request": request}, ) - def _cancel(_): + def _cancel(_: Any) -> None: # Abort connection immediately. txresponse._transport._producer.abortConnection() - d = defer.Deferred(_cancel) + d: Deferred[_ResultT] = Deferred(_cancel) txresponse.deliverBody( _ResponseReader( finished=d, @@ -495,7 +549,9 @@ class ScrapyAgent: return d - def _cb_bodydone(self, result, request, url): + def _cb_bodydone( + self, result: _ResultT, request: Request, url: str + ) -> Response | Failure: headers = self._headers_from_twisted_response(result["txresponse"]) respcls = responsetypes.from_args(headers=headers, url=url, body=result["body"]) try: @@ -514,6 +570,7 @@ class ScrapyAgent: protocol=protocol, ) if result.get("failure"): + assert result["failure"] result["failure"].value.response = response return result["failure"] return response @@ -521,47 +578,49 @@ class ScrapyAgent: @implementer(IBodyProducer) class _RequestBodyProducer: - def __init__(self, body): + def __init__(self, body: bytes): self.body = body self.length = len(body) - def startProducing(self, consumer): + def startProducing(self, consumer: IConsumer) -> Deferred[None]: consumer.write(self.body) - return defer.succeed(None) + return succeed(None) - def pauseProducing(self): + def pauseProducing(self) -> None: pass - def stopProducing(self): + def stopProducing(self) -> None: pass -class _ResponseReader(protocol.Protocol): +class _ResponseReader(Protocol): def __init__( self, - finished, - txresponse, - request, - maxsize, - warnsize, - fail_on_dataloss, - crawler, + finished: Deferred[_ResultT], + txresponse: TxResponse, + request: Request, + maxsize: int, + warnsize: int, + fail_on_dataloss: bool, + crawler: Crawler, ): - self._finished = finished - self._txresponse = txresponse - self._request = request - self._bodybuf = BytesIO() - self._maxsize = maxsize - self._warnsize = warnsize - self._fail_on_dataloss = fail_on_dataloss - self._fail_on_dataloss_warned = False - self._reached_warnsize = False - self._bytes_received = 0 - self._certificate = None - self._ip_address = None - self._crawler = crawler + self._finished: Deferred[_ResultT] = finished + self._txresponse: TxResponse = txresponse + self._request: Request = request + self._bodybuf: BytesIO = BytesIO() + self._maxsize: int = maxsize + self._warnsize: int = warnsize + self._fail_on_dataloss: bool = fail_on_dataloss + self._fail_on_dataloss_warned: bool = False + self._reached_warnsize: bool = False + self._bytes_received: int = 0 + self._certificate: ssl.Certificate | None = None + self._ip_address: ipaddress.IPv4Address | ipaddress.IPv6Address | None = None + self._crawler: Crawler = crawler - def _finish_response(self, flags=None, failure=None): + def _finish_response( + self, flags: list[str] | None = None, failure: Failure | None = None + ) -> None: self._finished.callback( { "txresponse": self._txresponse, @@ -573,7 +632,8 @@ class _ResponseReader(protocol.Protocol): } ) - def connectionMade(self): + def connectionMade(self) -> None: + assert self.transport if self._certificate is None: with suppress(AttributeError): self._certificate = ssl.Certificate( @@ -585,11 +645,12 @@ class _ResponseReader(protocol.Protocol): self.transport._producer.getPeer().host ) - def dataReceived(self, bodyBytes): + def dataReceived(self, bodyBytes: bytes) -> None: # This maybe called several times after cancel was called with buffered data. if self._finished.called: return + assert self.transport self._bodybuf.write(bodyBytes) self._bytes_received += len(bodyBytes) @@ -636,7 +697,7 @@ class _ResponseReader(protocol.Protocol): {"warnsize": self._warnsize, "request": self._request}, ) - def connectionLost(self, reason): + def connectionLost(self, reason: Failure = connectionDone) -> None: if self._finished.called: return diff --git a/scrapy/core/downloader/handlers/http2.py b/scrapy/core/downloader/handlers/http2.py index b2579362c..f0f9ceeb7 100644 --- a/scrapy/core/downloader/handlers/http2.py +++ b/scrapy/core/downloader/handlers/http2.py @@ -1,28 +1,33 @@ +from __future__ import annotations + from time import time -from typing import Optional, Type, TypeVar +from typing import TYPE_CHECKING from urllib.parse import urldefrag -from twisted.internet.base import DelayedCall -from twisted.internet.defer import Deferred from twisted.internet.error import TimeoutError from twisted.web.client import URI from scrapy.core.downloader.contextfactory import load_context_factory_from_settings from scrapy.core.downloader.webclient import _parse from scrapy.core.http2.agent import H2Agent, H2ConnectionPool, ScrapyProxyH2Agent -from scrapy.crawler import Crawler -from scrapy.http import Request, Response -from scrapy.settings import Settings -from scrapy.spiders import Spider from scrapy.utils.python import to_bytes -H2DownloadHandlerOrSubclass = TypeVar( - "H2DownloadHandlerOrSubclass", bound="H2DownloadHandler" -) +if TYPE_CHECKING: + from twisted.internet.base import DelayedCall + from twisted.internet.defer import Deferred + from twisted.web.iweb import IPolicyForHTTPS + + # typing.Self requires Python 3.11 + from typing_extensions import Self + + from scrapy.crawler import Crawler + from scrapy.http import Request, Response + from scrapy.settings import Settings + from scrapy.spiders import Spider class H2DownloadHandler: - def __init__(self, settings: Settings, crawler: Optional[Crawler] = None): + def __init__(self, settings: Settings, crawler: Crawler): self._crawler = crawler from twisted.internet import reactor @@ -31,12 +36,10 @@ class H2DownloadHandler: self._context_factory = load_context_factory_from_settings(settings, crawler) @classmethod - def from_crawler( - cls: Type[H2DownloadHandlerOrSubclass], crawler: Crawler - ) -> H2DownloadHandlerOrSubclass: + def from_crawler(cls, crawler: Crawler) -> Self: return cls(crawler.settings, crawler) - def download_request(self, request: Request, spider: Spider) -> Deferred: + def download_request(self, request: Request, spider: Spider) -> Deferred[Response]: agent = ScrapyH2Agent( context_factory=self._context_factory, pool=self._pool, @@ -54,11 +57,11 @@ class ScrapyH2Agent: def __init__( self, - context_factory, + context_factory: IPolicyForHTTPS, pool: H2ConnectionPool, connect_timeout: int = 10, - bind_address: Optional[bytes] = None, - crawler: Optional[Crawler] = None, + bind_address: bytes | None = None, + crawler: Crawler | None = None, ) -> None: self._context_factory = context_factory self._connect_timeout = connect_timeout @@ -66,7 +69,7 @@ class ScrapyH2Agent: self._pool = pool self._crawler = crawler - def _get_agent(self, request: Request, timeout: Optional[float]) -> H2Agent: + def _get_agent(self, request: Request, timeout: float | None) -> H2Agent: from twisted.internet import reactor bind_address = request.meta.get("bindaddress") or self._bind_address @@ -97,7 +100,7 @@ class ScrapyH2Agent: pool=self._pool, ) - def download_request(self, request: Request, spider: Spider) -> Deferred: + def download_request(self, request: Request, spider: Spider) -> Deferred[Response]: from twisted.internet import reactor timeout = request.meta.get("download_timeout") or self._connect_timeout diff --git a/scrapy/core/downloader/handlers/s3.py b/scrapy/core/downloader/handlers/s3.py index 1f7533759..870a26f04 100644 --- a/scrapy/core/downloader/handlers/s3.py +++ b/scrapy/core/downloader/handlers/s3.py @@ -1,21 +1,36 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + from scrapy.core.downloader.handlers.http import HTTPDownloadHandler from scrapy.exceptions import NotConfigured from scrapy.utils.boto import is_botocore_available from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.misc import build_from_crawler +if TYPE_CHECKING: + from twisted.internet.defer import Deferred + + # typing.Self requires Python 3.11 + from typing_extensions import Self + + from scrapy import Request, Spider + from scrapy.crawler import Crawler + from scrapy.http import Response + from scrapy.settings import BaseSettings + class S3DownloadHandler: def __init__( self, - settings, + settings: BaseSettings, *, - crawler=None, - aws_access_key_id=None, - aws_secret_access_key=None, - aws_session_token=None, - httpdownloadhandler=HTTPDownloadHandler, - **kw, + crawler: Crawler, + aws_access_key_id: str | None = None, + aws_secret_access_key: str | None = None, + aws_session_token: str | None = None, + httpdownloadhandler: type[HTTPDownloadHandler] = HTTPDownloadHandler, + **kw: Any, ): if not is_botocore_available(): raise NotConfigured("missing botocore library") @@ -43,8 +58,11 @@ class S3DownloadHandler: if kw: raise TypeError(f"Unexpected keyword arguments: {kw}") if not self.anon: + assert aws_access_key_id is not None + assert aws_secret_access_key is not None SignerCls = botocore.auth.AUTH_TYPE_MAPS["s3"] - self._signer = SignerCls( + # botocore.auth.BaseSigner doesn't have an __init__() with args, only subclasses do + self._signer = SignerCls( # type: ignore[call-arg] botocore.credentials.Credentials( aws_access_key_id, aws_secret_access_key, aws_session_token ) @@ -57,10 +75,10 @@ class S3DownloadHandler: self._download_http = _http_handler.download_request @classmethod - def from_crawler(cls, crawler, **kwargs): + def from_crawler(cls, crawler: Crawler, **kwargs: Any) -> Self: return cls(crawler.settings, crawler=crawler, **kwargs) - def download_request(self, request, spider): + def download_request(self, request: Request, spider: Spider) -> Deferred[Response]: p = urlparse_cached(request) scheme = "https" if request.meta.get("is_secure") else "http" bucket = p.hostname @@ -77,6 +95,7 @@ class S3DownloadHandler: headers=request.headers.to_unicode_dict(), data=request.body, ) + assert self._signer self._signer.add_auth(awsrequest) request = request.replace(url=url, headers=awsrequest.headers.items()) return self._download_http(request, spider) diff --git a/scrapy/core/downloader/middleware.py b/scrapy/core/downloader/middleware.py index dca13c01e..60e7adb2f 100644 --- a/scrapy/core/downloader/middleware.py +++ b/scrapy/core/downloader/middleware.py @@ -3,25 +3,34 @@ Downloader Middleware manager See documentation in docs/topics/downloader-middleware.rst """ -from typing import Any, Callable, Generator, List, Union, cast + +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, cast from twisted.internet.defer import Deferred, inlineCallbacks -from twisted.python.failure import Failure -from scrapy import Spider from scrapy.exceptions import _InvalidOutput from scrapy.http import Request, Response from scrapy.middleware import MiddlewareManager -from scrapy.settings import BaseSettings from scrapy.utils.conf import build_component_list from scrapy.utils.defer import deferred_from_coro, mustbe_deferred +if TYPE_CHECKING: + from collections.abc import Generator + + from twisted.python.failure import Failure + + from scrapy import Spider + from scrapy.settings import BaseSettings + class DownloaderMiddlewareManager(MiddlewareManager): component_name = "downloader middleware" @classmethod - def _get_mwlist_from_settings(cls, settings: BaseSettings) -> List[Any]: + def _get_mwlist_from_settings(cls, settings: BaseSettings) -> list[Any]: return build_component_list(settings.getwithbase("DOWNLOADER_MIDDLEWARES")) def _add_middleware(self, mw: Any) -> None: @@ -33,10 +42,15 @@ class DownloaderMiddlewareManager(MiddlewareManager): self.methods["process_exception"].appendleft(mw.process_exception) def download( - self, download_func: Callable, request: Request, spider: Spider - ) -> Deferred: + self, + download_func: Callable[[Request, Spider], Deferred[Response]], + request: Request, + spider: Spider, + ) -> Deferred[Response | Request]: @inlineCallbacks - def process_request(request: Request) -> Generator[Deferred, Any, Any]: + 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( @@ -51,12 +65,12 @@ class DownloaderMiddlewareManager(MiddlewareManager): ) if response: return response - return (yield download_func(request=request, spider=spider)) + return (yield download_func(request, spider)) @inlineCallbacks def process_response( - response: Union[Response, Request] - ) -> Generator[Deferred, Any, Union[Response, Request]]: + response: Response | Request, + ) -> Generator[Deferred[Any], Any, Response | Request]: if response is None: raise TypeError("Received None in process_response") elif isinstance(response, Request): @@ -79,7 +93,7 @@ class DownloaderMiddlewareManager(MiddlewareManager): @inlineCallbacks def process_exception( failure: Failure, - ) -> Generator[Deferred, Any, Union[Failure, Response, Request]]: + ) -> Generator[Deferred[Any], Any, Failure | Response | Request]: exception = failure.value for method in self.methods["process_exception"]: method = cast(Callable, method) @@ -97,7 +111,9 @@ class DownloaderMiddlewareManager(MiddlewareManager): return response return failure - deferred = mustbe_deferred(process_request, request) + deferred: Deferred[Response | Request] = mustbe_deferred( + process_request, request + ) deferred.addErrback(process_exception) deferred.addCallback(process_response) return deferred diff --git a/scrapy/core/downloader/tls.py b/scrapy/core/downloader/tls.py index 33cea7263..1ae66f614 100644 --- a/scrapy/core/downloader/tls.py +++ b/scrapy/core/downloader/tls.py @@ -1,5 +1,5 @@ import logging -from typing import Any, Dict +from typing import Any from OpenSSL import SSL from service_identity.exceptions import CertificateError @@ -21,7 +21,7 @@ METHOD_TLSv11 = "TLSv1.1" METHOD_TLSv12 = "TLSv1.2" -openssl_methods: Dict[str, int] = { +openssl_methods: dict[str, int] = { METHOD_TLS: SSL.SSLv23_METHOD, # protocol negotiation (recommended) METHOD_TLSv10: SSL.TLSv1_METHOD, # TLS 1.0 only METHOD_TLSv11: SSL.TLSv1_1_METHOD, # TLS 1.1 only diff --git a/scrapy/core/downloader/webclient.py b/scrapy/core/downloader/webclient.py index bb1f73805..ee10ae73b 100644 --- a/scrapy/core/downloader/webclient.py +++ b/scrapy/core/downloader/webclient.py @@ -1,20 +1,24 @@ +from __future__ import annotations + import re from time import time -from typing import Optional, Tuple +from typing import TYPE_CHECKING from urllib.parse import ParseResult, urldefrag, urlparse, urlunparse from twisted.internet import defer from twisted.internet.protocol import ClientFactory from twisted.web.http import HTTPClient -from scrapy import Request -from scrapy.http import Headers +from scrapy.http import Headers, Response from scrapy.responsetypes import responsetypes from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.python import to_bytes, to_unicode +if TYPE_CHECKING: + from scrapy import Request -def _parsed_url_args(parsed: ParseResult) -> Tuple[bytes, bytes, bytes, int, bytes]: + +def _parsed_url_args(parsed: ParseResult) -> tuple[bytes, bytes, bytes, int, bytes]: # Assume parsed is urlparse-d from Request.url, # which was passed via safe_url_string and is ascii-only. path_str = urlunparse(("", "", parsed.path or "/", parsed.params, parsed.query, "")) @@ -29,7 +33,7 @@ def _parsed_url_args(parsed: ParseResult) -> Tuple[bytes, bytes, bytes, int, byt return scheme, netloc, host, port, path -def _parse(url: str) -> Tuple[bytes, bytes, bytes, int, bytes]: +def _parse(url: str) -> tuple[bytes, bytes, bytes, int, bytes]: """Return tuple of (scheme, netloc, host, port, path), all in bytes except for port which is int. Assume url is from Request.url, which was passed via safe_url_string @@ -140,12 +144,12 @@ class ScrapyHTTPClientFactory(ClientFactory): # converting to bytes to comply to Twisted interface self.url: bytes = to_bytes(self._url, encoding="ascii") self.method: bytes = to_bytes(request.method, encoding="ascii") - self.body: Optional[bytes] = request.body or None + self.body: bytes | None = request.body or None self.headers: Headers = Headers(request.headers) - self.response_headers: Optional[Headers] = None + self.response_headers: Headers | None = None self.timeout: float = request.meta.get("download_timeout") or timeout self.start_time: float = time() - self.deferred: defer.Deferred = defer.Deferred().addCallback( + self.deferred: defer.Deferred[Response] = defer.Deferred().addCallback( self._build_response, request ) @@ -155,7 +159,7 @@ class ScrapyHTTPClientFactory(ClientFactory): # needed to add the callback _waitForDisconnect. # Specifically this avoids the AttributeError exception when # clientConnectionFailed method is called. - self._disconnectedDeferred: defer.Deferred = defer.Deferred() + self._disconnectedDeferred: defer.Deferred[None] = defer.Deferred() self._set_connection_attributes(request) diff --git a/scrapy/core/engine.py b/scrapy/core/engine.py index 545cd401f..d056a00ba 100644 --- a/scrapy/core/engine.py +++ b/scrapy/core/engine.py @@ -4,60 +4,58 @@ This is the Scrapy engine which controls the Scheduler, Downloader and Spider. For more information see docs/topics/architecture.rst """ + +from __future__ import annotations + import logging from time import time -from typing import ( - TYPE_CHECKING, - Any, - Callable, - Generator, - Iterable, - Iterator, - Optional, - Set, - Type, - Union, - cast, -) +from typing import TYPE_CHECKING, Any, TypeVar, cast +from itemadapter import is_item from twisted.internet.defer import Deferred, inlineCallbacks, succeed from twisted.internet.task import LoopingCall from twisted.python.failure import Failure from scrapy import signals from scrapy.core.downloader import Downloader -from scrapy.core.scraper import Scraper -from scrapy.exceptions import CloseSpider, DontCloseSpider +from scrapy.core.scraper import Scraper, _HandleOutputDeferred +from scrapy.exceptions import CloseSpider, DontCloseSpider, IgnoreRequest from scrapy.http import Request, Response from scrapy.logformatter import LogFormatter -from scrapy.settings import BaseSettings, Settings +from scrapy.settings import Settings from scrapy.signalmanager import SignalManager -from scrapy.spiders import Spider from scrapy.utils.log import failure_to_exc_info, logformatter_adapter from scrapy.utils.misc import build_from_crawler, load_object from scrapy.utils.reactor import CallLaterOnce if TYPE_CHECKING: + from collections.abc import Callable, Generator, Iterable, Iterator + from scrapy.core.scheduler import BaseScheduler from scrapy.crawler import Crawler + from scrapy.settings import BaseSettings + from scrapy.spiders import Spider + logger = logging.getLogger(__name__) +_T = TypeVar("_T") + class Slot: def __init__( self, start_requests: Iterable[Request], close_if_idle: bool, - nextcall: CallLaterOnce, - scheduler: "BaseScheduler", + nextcall: CallLaterOnce[None], + scheduler: BaseScheduler, ) -> None: - self.closing: Optional[Deferred] = None - self.inprogress: Set[Request] = set() - self.start_requests: Optional[Iterator[Request]] = iter(start_requests) + self.closing: Deferred[None] | None = None + self.inprogress: set[Request] = set() + self.start_requests: Iterator[Request] | None = iter(start_requests) self.close_if_idle: bool = close_if_idle - self.nextcall: CallLaterOnce = nextcall - self.scheduler: "BaseScheduler" = scheduler + self.nextcall: CallLaterOnce[None] = nextcall + self.scheduler: BaseScheduler = scheduler self.heartbeat: LoopingCall = LoopingCall(nextcall.schedule) def add_request(self, request: Request) -> None: @@ -67,7 +65,7 @@ class Slot: self.inprogress.remove(request) self._maybe_fire_closing() - def close(self) -> Deferred: + def close(self) -> Deferred[None]: self.closing = Deferred() self._maybe_fire_closing() return self.closing @@ -82,29 +80,35 @@ class Slot: class ExecutionEngine: - def __init__(self, crawler: "Crawler", spider_closed_callback: Callable) -> None: - self.crawler: "Crawler" = crawler + def __init__( + self, + crawler: Crawler, + spider_closed_callback: Callable[[Spider], Deferred[None] | None], + ) -> None: + self.crawler: Crawler = crawler self.settings: Settings = crawler.settings self.signals: SignalManager = crawler.signals assert crawler.logformatter self.logformatter: LogFormatter = crawler.logformatter - self.slot: Optional[Slot] = None - self.spider: Optional[Spider] = None + self.slot: Slot | None = None + self.spider: Spider | None = None self.running: bool = False self.paused: bool = False - self.scheduler_cls: Type["BaseScheduler"] = self._get_scheduler_class( + self.scheduler_cls: type[BaseScheduler] = self._get_scheduler_class( crawler.settings ) - downloader_cls: Type[Downloader] = load_object(self.settings["DOWNLOADER"]) + downloader_cls: type[Downloader] = load_object(self.settings["DOWNLOADER"]) self.downloader: Downloader = downloader_cls(crawler) - self.scraper = Scraper(crawler) - self._spider_closed_callback: Callable = spider_closed_callback - self.start_time: Optional[float] = None + self.scraper: Scraper = Scraper(crawler) + self._spider_closed_callback: Callable[[Spider], Deferred[None] | None] = ( + spider_closed_callback + ) + self.start_time: float | None = None - def _get_scheduler_class(self, settings: BaseSettings) -> Type["BaseScheduler"]: + def _get_scheduler_class(self, settings: BaseSettings) -> type[BaseScheduler]: from scrapy.core.scheduler import BaseScheduler - scheduler_cls: Type = load_object(settings["SCHEDULER"]) + scheduler_cls: type[BaseScheduler] = load_object(settings["SCHEDULER"]) if not issubclass(scheduler_cls, BaseScheduler): raise TypeError( f"The provided scheduler class ({settings['SCHEDULER']})" @@ -113,20 +117,20 @@ class ExecutionEngine: return scheduler_cls @inlineCallbacks - def start(self) -> Generator[Deferred, Any, None]: + def start(self) -> Generator[Deferred[Any], Any, None]: if self.running: raise RuntimeError("Engine already running") self.start_time = time() yield self.signals.send_catch_log_deferred(signal=signals.engine_started) self.running = True - self._closewait: Deferred = Deferred() + self._closewait: Deferred[None] = Deferred() yield self._closewait - def stop(self) -> Deferred: + def stop(self) -> Deferred[None]: """Gracefully stop the execution engine""" @inlineCallbacks - def _finish_stopping_engine(_: Any) -> Generator[Deferred, Any, None]: + def _finish_stopping_engine(_: Any) -> Generator[Deferred[Any], Any, None]: yield self.signals.send_catch_log_deferred(signal=signals.engine_stopped) self._closewait.callback(None) @@ -141,7 +145,7 @@ class ExecutionEngine: ) return dfd.addBoth(_finish_stopping_engine) - def close(self) -> Deferred: + def close(self) -> Deferred[None]: """ Gracefully close the execution engine. If it has already been started, stop it. In all cases, close the spider and the downloader. @@ -178,7 +182,7 @@ class ExecutionEngine: if self.slot.start_requests is not None and not self._needs_backout(): try: - request = next(self.slot.start_requests) + request_or_item = next(self.slot.start_requests) except StopIteration: self.slot.start_requests = None except Exception: @@ -189,7 +193,16 @@ class ExecutionEngine: extra={"spider": self.spider}, ) else: - self.crawl(request) + if isinstance(request_or_item, Request): + self.crawl(request_or_item) + elif is_item(request_or_item): + self.scraper.start_itemproc(request_or_item, response=None) + else: + logger.error( + f"Got {request_or_item!r} among start requests. Only " + f"requests and items are supported. It will be " + f"ignored." + ) if self.spider_is_idle() and self.slot.close_if_idle: self._spider_idle() @@ -204,7 +217,7 @@ class ExecutionEngine: or self.scraper.slot.needs_backout() ) - def _next_request_from_scheduler(self) -> Optional[Deferred]: + def _next_request_from_scheduler(self) -> Deferred[None] | None: assert self.slot is not None # typing assert self.spider is not None # typing @@ -212,7 +225,7 @@ class ExecutionEngine: if request is None: return None - d = self._download(request) + d: Deferred[Response | Request] = self._download(request) d.addBoth(self._handle_downloader_output, request) d.addErrback( lambda f: logger.info( @@ -226,8 +239,8 @@ class ExecutionEngine: assert self.slot self.slot.remove_request(request) - d.addBoth(_remove_request) - d.addErrback( + d2: Deferred[None] = d.addBoth(_remove_request) + d2.addErrback( lambda f: logger.info( "Error while removing request from slot", exc_info=failure_to_exc_info(f), @@ -235,19 +248,19 @@ class ExecutionEngine: ) ) slot = self.slot - d.addBoth(lambda _: slot.nextcall.schedule()) - d.addErrback( + d2.addBoth(lambda _: slot.nextcall.schedule()) + d2.addErrback( lambda f: logger.info( "Error while scheduling new request", exc_info=failure_to_exc_info(f), extra={"spider": self.spider}, ) ) - return d + return d2 def _handle_downloader_output( - self, result: Union[Request, Response, Failure], request: Request - ) -> Optional[Deferred]: + self, result: Request | Response | Failure, request: Request + ) -> _HandleOutputDeferred | None: assert self.spider is not None # typing if not isinstance(result, (Request, Response, Failure)): @@ -291,33 +304,42 @@ class ExecutionEngine: self.slot.nextcall.schedule() # type: ignore[union-attr] def _schedule_request(self, request: Request, spider: Spider) -> None: - self.signals.send_catch_log( - signals.request_scheduled, request=request, spider=spider + request_scheduled_result = self.signals.send_catch_log( + signals.request_scheduled, + request=request, + spider=spider, + dont_log=IgnoreRequest, ) + for handler, result in request_scheduled_result: + if isinstance(result, Failure) and isinstance(result.value, IgnoreRequest): + return if not self.slot.scheduler.enqueue_request(request): # type: ignore[union-attr] self.signals.send_catch_log( signals.request_dropped, request=request, spider=spider ) - def download(self, request: Request) -> Deferred: + def download(self, request: Request) -> Deferred[Response]: """Return a Deferred which fires with a Response as result, only downloader middlewares are applied""" if self.spider is None: raise RuntimeError(f"No open spider to crawl: {request}") - return self._download(request).addBoth(self._downloaded, request) + d: Deferred[Response | Request] = self._download(request) + # Deferred.addBoth() overloads don't seem to support a Union[_T, Deferred[_T]] return type + d2: Deferred[Response] = d.addBoth(self._downloaded, request) # type: ignore[call-overload] + return d2 def _downloaded( - self, result: Union[Response, Request, Failure], request: Request - ) -> Union[Deferred, Response, Failure]: + self, result: Response | Request | Failure, request: Request + ) -> Deferred[Response] | Response | Failure: assert self.slot is not None # typing self.slot.remove_request(request) return self.download(result) if isinstance(result, Request) else result - def _download(self, request: Request) -> Deferred: + def _download(self, request: Request) -> Deferred[Response | Request]: assert self.slot is not None # typing self.slot.add_request(request) - def _on_success(result: Union[Response, Request]) -> Union[Response, Request]: + def _on_success(result: Response | Request) -> Response | Request: if not isinstance(result, (Response, Request)): raise TypeError( f"Incorrect type: expected Response or Request, got {type(result)}: {result!r}" @@ -339,21 +361,24 @@ class ExecutionEngine: ) return result - def _on_complete(_: Any) -> Any: + def _on_complete(_: _T) -> _T: assert self.slot is not None self.slot.nextcall.schedule() return _ assert self.spider is not None - dwld = self.downloader.fetch(request, self.spider) - dwld.addCallbacks(_on_success) + dwld: Deferred[Response | Request] = self.downloader.fetch(request, self.spider) + dwld.addCallback(_on_success) dwld.addBoth(_on_complete) return dwld @inlineCallbacks def open_spider( - self, spider: Spider, start_requests: Iterable = (), close_if_idle: bool = True - ) -> Generator[Deferred, Any, None]: + self, + spider: Spider, + start_requests: Iterable[Request] = (), + close_if_idle: bool = True, + ) -> Generator[Deferred[Any], Any, None]: 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}) @@ -365,7 +390,8 @@ class ExecutionEngine: self.slot = Slot(start_requests, close_if_idle, nextcall, scheduler) self.spider = spider if hasattr(scheduler, "open"): - yield scheduler.open(spider) + if d := scheduler.open(spider): + yield d yield self.scraper.open_spider(spider) assert self.crawler.stats self.crawler.stats.open_spider(spider) @@ -398,7 +424,7 @@ class ExecutionEngine: assert isinstance(ex, CloseSpider) # typing self.close_spider(self.spider, reason=ex.reason) - def close_spider(self, spider: Spider, reason: str = "cancelled") -> Deferred: + def close_spider(self, spider: Spider, reason: str = "cancelled") -> Deferred[None]: """Close (cancel) spider and clear all its outstanding requests""" if self.slot is None: raise RuntimeError("Engine slot not assigned") @@ -412,7 +438,7 @@ class ExecutionEngine: dfd = self.slot.close() - def log_failure(msg: str) -> Callable: + 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} diff --git a/scrapy/core/http2/agent.py b/scrapy/core/http2/agent.py index 215ea9716..45f32daaa 100644 --- a/scrapy/core/http2/agent.py +++ b/scrapy/core/http2/agent.py @@ -1,10 +1,10 @@ +from __future__ import annotations + from collections import deque -from typing import Deque, Dict, List, Optional, Tuple +from typing import TYPE_CHECKING from twisted.internet import defer -from twisted.internet.base import ReactorBase from twisted.internet.defer import Deferred -from twisted.internet.endpoints import HostnameEndpoint from twisted.python.failure import Failure from twisted.web.client import ( URI, @@ -16,9 +16,17 @@ from twisted.web.error import SchemeNotSupported from scrapy.core.downloader.contextfactory import AcceptableProtocolsContextFactory from scrapy.core.http2.protocol import H2ClientFactory, H2ClientProtocol -from scrapy.http.request import Request -from scrapy.settings import Settings -from scrapy.spiders import Spider + +if TYPE_CHECKING: + from twisted.internet.base import ReactorBase + from twisted.internet.endpoints import HostnameEndpoint + + from scrapy.http import Request, Response + from scrapy.settings import Settings + from scrapy.spiders import Spider + + +ConnectionKeyT = tuple[bytes, bytes, int] class H2ConnectionPool: @@ -28,19 +36,21 @@ class H2ConnectionPool: # Store a dictionary which is used to get the respective # H2ClientProtocolInstance using the key as Tuple(scheme, hostname, port) - self._connections: Dict[Tuple, H2ClientProtocol] = {} + self._connections: dict[ConnectionKeyT, H2ClientProtocol] = {} # Save all requests that arrive before the connection is established - self._pending_requests: Dict[Tuple, Deque[Deferred]] = {} + self._pending_requests: dict[ + ConnectionKeyT, deque[Deferred[H2ClientProtocol]] + ] = {} def get_connection( - self, key: Tuple, uri: URI, endpoint: HostnameEndpoint - ) -> Deferred: + self, key: ConnectionKeyT, uri: URI, endpoint: HostnameEndpoint + ) -> Deferred[H2ClientProtocol]: if key in self._pending_requests: # Received a request while connecting to remote # Create a deferred which will fire with the H2ClientProtocol # instance - d: Deferred = Deferred() + d: Deferred[H2ClientProtocol] = Deferred() self._pending_requests[key].append(d) return d @@ -54,22 +64,24 @@ class H2ConnectionPool: return self._new_connection(key, uri, endpoint) def _new_connection( - self, key: Tuple, uri: URI, endpoint: HostnameEndpoint - ) -> Deferred: + self, key: ConnectionKeyT, uri: URI, endpoint: HostnameEndpoint + ) -> Deferred[H2ClientProtocol]: self._pending_requests[key] = deque() - conn_lost_deferred: Deferred = Deferred() + conn_lost_deferred: Deferred[list[BaseException]] = Deferred() conn_lost_deferred.addCallback(self._remove_connection, key) factory = H2ClientFactory(uri, self.settings, conn_lost_deferred) conn_d = endpoint.connect(factory) conn_d.addCallback(self.put_connection, key) - d: Deferred = Deferred() + d: Deferred[H2ClientProtocol] = Deferred() self._pending_requests[key].append(d) return d - def put_connection(self, conn: H2ClientProtocol, key: Tuple) -> H2ClientProtocol: + def put_connection( + self, conn: H2ClientProtocol, key: ConnectionKeyT + ) -> H2ClientProtocol: self._connections[key] = conn # Now as we have established a proper HTTP/2 connection @@ -81,7 +93,9 @@ class H2ConnectionPool: return conn - def _remove_connection(self, errors: List[BaseException], key: Tuple) -> None: + def _remove_connection( + self, errors: list[BaseException], key: ConnectionKeyT + ) -> None: self._connections.pop(key) # Call the errback of all the pending requests for this connection @@ -107,8 +121,8 @@ class H2Agent: reactor: ReactorBase, pool: H2ConnectionPool, context_factory: BrowserLikePolicyForHTTPS = BrowserLikePolicyForHTTPS(), - connect_timeout: Optional[float] = None, - bind_address: Optional[bytes] = None, + connect_timeout: float | None = None, + bind_address: bytes | None = None, ) -> None: self._reactor = reactor self._pool = pool @@ -119,17 +133,17 @@ class H2Agent: self._reactor, self._context_factory, connect_timeout, bind_address ) - def get_endpoint(self, uri: URI): + def get_endpoint(self, uri: URI) -> HostnameEndpoint: return self.endpoint_factory.endpointForURI(uri) - def get_key(self, uri: URI) -> Tuple: + def get_key(self, uri: URI) -> ConnectionKeyT: """ Arguments: uri - URI obtained directly from request URL """ return uri.scheme, uri.host, uri.port - def request(self, request: Request, spider: Spider) -> Deferred: + def request(self, request: Request, spider: Spider) -> Deferred[Response]: uri = URI.fromBytes(bytes(request.url, encoding="utf-8")) try: endpoint = self.get_endpoint(uri) @@ -137,9 +151,11 @@ class H2Agent: return defer.fail(Failure()) key = self.get_key(uri) - d = self._pool.get_connection(key, uri, endpoint) - d.addCallback(lambda conn: conn.request(request, spider)) - return d + d: Deferred[H2ClientProtocol] = self._pool.get_connection(key, uri, endpoint) + d2: Deferred[Response] = d.addCallback( + lambda conn: conn.request(request, spider) + ) + return d2 class ScrapyProxyH2Agent(H2Agent): @@ -149,8 +165,8 @@ class ScrapyProxyH2Agent(H2Agent): proxy_uri: URI, pool: H2ConnectionPool, context_factory: BrowserLikePolicyForHTTPS = BrowserLikePolicyForHTTPS(), - connect_timeout: Optional[float] = None, - bind_address: Optional[bytes] = None, + connect_timeout: float | None = None, + bind_address: bytes | None = None, ) -> None: super().__init__( reactor=reactor, @@ -161,9 +177,9 @@ class ScrapyProxyH2Agent(H2Agent): ) self._proxy_uri = proxy_uri - def get_endpoint(self, uri: URI): + def get_endpoint(self, uri: URI) -> HostnameEndpoint: return self.endpoint_factory.endpointForURI(self._proxy_uri) - def get_key(self, uri: URI) -> Tuple: + def get_key(self, uri: URI) -> ConnectionKeyT: """We use the proxy uri instead of uri obtained from request url""" - return "http-proxy", self._proxy_uri.host, self._proxy_uri.port + return b"http-proxy", self._proxy_uri.host, self._proxy_uri.port diff --git a/scrapy/core/http2/protocol.py b/scrapy/core/http2/protocol.py index bc8da50d7..23335b7b2 100644 --- a/scrapy/core/http2/protocol.py +++ b/scrapy/core/http2/protocol.py @@ -1,9 +1,10 @@ +from __future__ import annotations + import ipaddress import itertools import logging from collections import deque -from ipaddress import IPv4Address, IPv6Address -from typing import Dict, List, Optional, Union +from typing import TYPE_CHECKING, Any from h2.config import H2Configuration from h2.connection import H2Connection @@ -20,20 +21,30 @@ from h2.events import ( WindowUpdated, ) from h2.exceptions import FrameTooLargeError, H2Error -from twisted.internet.defer import Deferred from twisted.internet.error import TimeoutError -from twisted.internet.interfaces import IHandshakeListener, IProtocolNegotiationFactory +from twisted.internet.interfaces import ( + IAddress, + IHandshakeListener, + IProtocolNegotiationFactory, +) from twisted.internet.protocol import Factory, Protocol, connectionDone from twisted.internet.ssl import Certificate from twisted.protocols.policies import TimeoutMixin -from twisted.python.failure import Failure -from twisted.web.client import URI from zope.interface import implementer from scrapy.core.http2.stream import Stream, StreamCloseReason -from scrapy.http import Request -from scrapy.settings import Settings -from scrapy.spiders import Spider +from scrapy.http import Request, Response + +if TYPE_CHECKING: + from ipaddress import IPv4Address, IPv6Address + + from twisted.internet.defer import Deferred + from twisted.python.failure import Failure + from twisted.web.client import URI + + from scrapy.settings import Settings + from scrapy.spiders import Spider + logger = logging.getLogger(__name__) @@ -52,7 +63,7 @@ class InvalidNegotiatedProtocol(H2Error): class RemoteTerminatedConnection(H2Error): def __init__( self, - remote_ip_address: Optional[Union[IPv4Address, IPv6Address]], + remote_ip_address: IPv4Address | IPv6Address | None, event: ConnectionTerminated, ) -> None: self.remote_ip_address = remote_ip_address @@ -63,9 +74,7 @@ class RemoteTerminatedConnection(H2Error): class MethodNotAllowed405(H2Error): - def __init__( - self, remote_ip_address: Optional[Union[IPv4Address, IPv6Address]] - ) -> None: + def __init__(self, remote_ip_address: IPv4Address | IPv6Address | None) -> None: self.remote_ip_address = remote_ip_address def __str__(self) -> str: @@ -77,7 +86,10 @@ class H2ClientProtocol(Protocol, TimeoutMixin): IDLE_TIMEOUT = 240 def __init__( - self, uri: URI, settings: Settings, conn_lost_deferred: Deferred + self, + uri: URI, + settings: Settings, + conn_lost_deferred: Deferred[list[BaseException]], ) -> None: """ Arguments: @@ -88,7 +100,7 @@ class H2ClientProtocol(Protocol, TimeoutMixin): conn_lost_deferred -- Deferred fires with the reason: Failure to notify that connection was lost """ - self._conn_lost_deferred = conn_lost_deferred + self._conn_lost_deferred: Deferred[list[BaseException]] = conn_lost_deferred config = H2Configuration(client_side=True, header_encoding="utf-8") self.conn = H2Connection(config=config) @@ -99,19 +111,19 @@ class H2ClientProtocol(Protocol, TimeoutMixin): self._stream_id_generator = itertools.count(start=1, step=2) # Streams are stored in a dictionary keyed off their stream IDs - self.streams: Dict[int, Stream] = {} + self.streams: dict[int, Stream] = {} # If requests are received before connection is made we keep # all requests in a pool and send them as the connection is made - self._pending_request_stream_pool: deque = deque() + self._pending_request_stream_pool: deque[Stream] = deque() # Save an instance of errors raised which lead to losing the connection # We pass these instances to the streams ResponseFailed() failure - self._conn_lost_errors: List[BaseException] = [] + self._conn_lost_errors: list[BaseException] = [] # Some meta data of this connection # initialized when connection is successfully made - self.metadata: Dict = { + self.metadata: dict[str, Any] = { # Peer certificate instance "certificate": None, # Address of the server we are connected to which @@ -204,14 +216,14 @@ class H2ClientProtocol(Protocol, TimeoutMixin): data = self.conn.data_to_send() self.transport.write(data) - def request(self, request: Request, spider: Spider) -> Deferred: + def request(self, request: Request, spider: Spider) -> Deferred[Response]: if not isinstance(request, Request): raise TypeError( f"Expected scrapy.http.Request, received {request.__class__.__qualname__}" ) stream = self._new_stream(request, spider) - d = stream.get_response() + d: Deferred[Response] = stream.get_response() # Add the stream to the request pool self._pending_request_stream_pool.append(stream) @@ -236,7 +248,7 @@ class H2ClientProtocol(Protocol, TimeoutMixin): self.conn.initiate_connection() self._write_to_transport() - def _lose_connection_with_error(self, errors: List[BaseException]) -> None: + def _lose_connection_with_error(self, errors: list[BaseException]) -> None: """Helper function to lose the connection with the error sent as a reason""" self._conn_lost_errors += errors @@ -339,7 +351,7 @@ class H2ClientProtocol(Protocol, TimeoutMixin): self._pending_request_stream_pool.clear() self.conn.close_connection() - def _handle_events(self, events: List[Event]) -> None: + def _handle_events(self, events: list[Event]) -> None: """Private method which acts as a bridge between the events received from the HTTP/2 data and IH2EventsHandler @@ -425,14 +437,17 @@ class H2ClientProtocol(Protocol, TimeoutMixin): @implementer(IProtocolNegotiationFactory) class H2ClientFactory(Factory): def __init__( - self, uri: URI, settings: Settings, conn_lost_deferred: Deferred + self, + uri: URI, + settings: Settings, + conn_lost_deferred: Deferred[list[BaseException]], ) -> None: self.uri = uri self.settings = settings self.conn_lost_deferred = conn_lost_deferred - def buildProtocol(self, addr) -> H2ClientProtocol: + def buildProtocol(self, addr: IAddress) -> H2ClientProtocol: return H2ClientProtocol(self.uri, self.settings, self.conn_lost_deferred) - def acceptableProtocols(self) -> List[bytes]: + def acceptableProtocols(self) -> list[bytes]: return [PROTOCOL_NAME] diff --git a/scrapy/core/http2/stream.py b/scrapy/core/http2/stream.py index 39d5921f4..a4dc89c18 100644 --- a/scrapy/core/http2/stream.py +++ b/scrapy/core/http2/stream.py @@ -1,23 +1,26 @@ +from __future__ import annotations + import logging from enum import Enum from io import BytesIO -from typing import TYPE_CHECKING, Dict, List, Optional, Tuple -from urllib.parse import urlparse +from typing import TYPE_CHECKING, Any from h2.errors import ErrorCodes from h2.exceptions import H2Error, ProtocolError, StreamClosedError -from hpack import HeaderTuple from twisted.internet.defer import CancelledError, Deferred from twisted.internet.error import ConnectionClosed from twisted.python.failure import Failure from twisted.web.client import ResponseFailed -from scrapy.http import Request from scrapy.http.headers import Headers from scrapy.responsetypes import responsetypes +from scrapy.utils.httpobj import urlparse_cached if TYPE_CHECKING: + from hpack import HeaderTuple + from scrapy.core.http2.protocol import H2ClientProtocol + from scrapy.http import Request, Response logger = logging.getLogger(__name__) @@ -87,7 +90,7 @@ class Stream: self, stream_id: int, request: Request, - protocol: "H2ClientProtocol", + protocol: H2ClientProtocol, download_maxsize: int = 0, download_warnsize: int = 0, ) -> None: @@ -99,7 +102,7 @@ class Stream: """ self.stream_id: int = stream_id self._request: Request = request - self._protocol: "H2ClientProtocol" = protocol + self._protocol: H2ClientProtocol = protocol self._download_maxsize = self._request.meta.get( "download_maxsize", download_maxsize @@ -110,18 +113,18 @@ class Stream: # Metadata of an HTTP/2 connection stream # initialized when stream is instantiated - self.metadata: Dict = { - "request_content_length": 0 - if self._request.body is None - else len(self._request.body), + self.metadata: dict[str, Any] = { + "request_content_length": ( + 0 if self._request.body is None else len(self._request.body) + ), # Flag to keep track whether the stream has initiated the request "request_sent": False, # Flag to track whether we have logged about exceeding download warnsize "reached_warnsize": False, # Each time we send a data frame, we will decrease value by the amount send. - "remaining_content_length": 0 - if self._request.body is None - else len(self._request.body), + "remaining_content_length": ( + 0 if self._request.body is None else len(self._request.body) + ), # Flag to keep track whether client (self) have closed this stream "stream_closed_local": False, # Flag to keep track whether the server has closed the stream @@ -131,7 +134,7 @@ class Stream: # Private variable used to build the response # this response is then converted to appropriate Response class # passed to the response deferred callback - self._response: Dict = { + self._response: dict[str, Any] = { # Data received frame by frame from the server is appended # and passed to the response Deferred when completely received. "body": BytesIO(), @@ -142,7 +145,7 @@ class Stream: "headers": Headers({}), } - def _cancel(_) -> None: + def _cancel(_: Any) -> None: # Close this stream as gracefully as possible # If the associated request is initiated we reset this stream # else we directly call close() method @@ -151,7 +154,7 @@ class Stream: else: self.close(StreamCloseReason.CANCELLED) - self._deferred_response: Deferred = Deferred(_cancel) + self._deferred_response: Deferred[Response] = Deferred(_cancel) def __repr__(self) -> str: return f"Stream(id={self.stream_id!r})" @@ -177,7 +180,7 @@ class Stream: and not self.metadata["reached_warnsize"] ) - def get_response(self) -> Deferred: + def get_response(self) -> Deferred[Response]: """Simply return a Deferred which fires when response from the asynchronous request is available """ @@ -185,7 +188,7 @@ class Stream: def check_request_url(self) -> bool: # Make sure that we are sending the request to the correct URL - url = urlparse(self._request.url) + url = urlparse_cached(self._request) return ( url.netloc == str(self._protocol.metadata["uri"].host, "utf-8") or url.netloc == str(self._protocol.metadata["uri"].netloc, "utf-8") @@ -193,8 +196,8 @@ class Stream: == f'{self._protocol.metadata["ip_address"]}:{self._protocol.metadata["uri"].port}' ) - def _get_request_headers(self) -> List[Tuple[str, str]]: - url = urlparse(self._request.url) + def _get_request_headers(self) -> list[tuple[str, str]]: + url = urlparse_cached(self._request) path = url.path if url.query: @@ -346,7 +349,7 @@ class Stream: self._response["flow_controlled_size"], self.stream_id ) - def receive_headers(self, headers: List[HeaderTuple]) -> None: + def receive_headers(self, headers: list[HeaderTuple]) -> None: for name, value in headers: self._response["headers"].appendlist(name, value) @@ -379,7 +382,7 @@ class Stream: def close( self, reason: StreamCloseReason, - errors: Optional[List[BaseException]] = None, + errors: list[BaseException] | None = None, from_protocol: bool = False, ) -> None: """Based on the reason sent we will handle each case.""" diff --git a/scrapy/core/scheduler.py b/scrapy/core/scheduler.py index f41b83a67..bebee1236 100644 --- a/scrapy/core/scheduler.py +++ b/scrapy/core/scheduler.py @@ -4,22 +4,28 @@ import json import logging from abc import abstractmethod from pathlib import Path -from typing import TYPE_CHECKING, Any, Optional, Type, TypeVar, cast +from typing import TYPE_CHECKING, Any, cast -from twisted.internet.defer import Deferred +# working around https://github.com/sphinx-doc/sphinx/issues/10400 +from twisted.internet.defer import Deferred # noqa: TC002 -from scrapy.crawler import Crawler -from scrapy.dupefilters import BaseDupeFilter -from scrapy.http.request import Request -from scrapy.spiders import Spider -from scrapy.statscollectors import StatsCollector +from scrapy.spiders import Spider # noqa: TC001 from scrapy.utils.job import job_dir from scrapy.utils.misc import build_from_crawler, load_object if TYPE_CHECKING: + # requires queuelib >= 1.6.2 + from queuelib.queue import BaseQueue + # typing.Self requires Python 3.11 from typing_extensions import Self + from scrapy.crawler import Crawler + from scrapy.dupefilters import BaseDupeFilter + from scrapy.http.request import Request + from scrapy.pqueues import ScrapyPriorityQueue + from scrapy.statscollectors import StatsCollector + logger = logging.getLogger(__name__) @@ -67,7 +73,7 @@ class BaseScheduler(metaclass=BaseSchedulerMeta): """ return cls() - def open(self, spider: Spider) -> Optional[Deferred]: + def open(self, spider: Spider) -> Deferred[None] | None: """ Called when the spider is opened by the engine. It receives the spider instance as argument and it's useful to execute initialization code. @@ -77,7 +83,7 @@ class BaseScheduler(metaclass=BaseSchedulerMeta): """ pass - def close(self, reason: str) -> Optional[Deferred]: + def close(self, reason: str) -> Deferred[None] | None: """ Called when the spider is closed by the engine. It receives the reason why the crawl finished as argument and it's useful to execute cleaning code. @@ -109,7 +115,7 @@ class BaseScheduler(metaclass=BaseSchedulerMeta): raise NotImplementedError() @abstractmethod - def next_request(self) -> Optional[Request]: + def next_request(self) -> Request | None: """ Return the next :class:`~scrapy.http.Request` to be processed, or ``None`` to indicate that there are no requests to be considered ready at the moment. @@ -121,9 +127,6 @@ class BaseScheduler(metaclass=BaseSchedulerMeta): raise NotImplementedError() -SchedulerTV = TypeVar("SchedulerTV", bound="Scheduler") - - class Scheduler(BaseScheduler): """ Default Scrapy scheduler. This implementation also handles duplication @@ -178,25 +181,25 @@ class Scheduler(BaseScheduler): def __init__( self, dupefilter: BaseDupeFilter, - jobdir: Optional[str] = None, - dqclass=None, - mqclass=None, + jobdir: str | None = None, + dqclass: type[BaseQueue] | None = None, + mqclass: type[BaseQueue] | None = None, logunser: bool = False, - stats: Optional[StatsCollector] = None, - pqclass=None, - crawler: Optional[Crawler] = None, + stats: StatsCollector | None = None, + pqclass: type[ScrapyPriorityQueue] | None = None, + crawler: Crawler | None = None, ): self.df: BaseDupeFilter = dupefilter - self.dqdir: Optional[str] = self._dqdir(jobdir) - self.pqclass = pqclass - self.dqclass = dqclass - self.mqclass = mqclass + self.dqdir: str | None = self._dqdir(jobdir) + self.pqclass: type[ScrapyPriorityQueue] | None = pqclass + self.dqclass: type[BaseQueue] | None = dqclass + self.mqclass: type[BaseQueue] | None = mqclass self.logunser: bool = logunser - self.stats: Optional[StatsCollector] = stats - self.crawler: Optional[Crawler] = crawler + self.stats: StatsCollector | None = stats + self.crawler: Crawler | None = crawler @classmethod - def from_crawler(cls: Type[SchedulerTV], crawler: Crawler) -> SchedulerTV: + def from_crawler(cls, crawler: Crawler) -> Self: """ Factory method, initializes the scheduler with arguments taken from the crawl settings """ @@ -215,18 +218,18 @@ class Scheduler(BaseScheduler): def has_pending_requests(self) -> bool: return len(self) > 0 - def open(self, spider: Spider) -> Optional[Deferred]: + def open(self, spider: Spider) -> Deferred[None] | None: """ (1) initialize the memory queue (2) initialize the disk queue if the ``jobdir`` attribute is a valid directory (3) return the result of the dupefilter's ``open`` method """ - self.spider = spider - self.mqs = self._mq() - self.dqs = self._dq() if self.dqdir else None + self.spider: Spider = spider + self.mqs: ScrapyPriorityQueue = self._mq() + self.dqs: ScrapyPriorityQueue | None = self._dq() if self.dqdir else None return self.df.open() - def close(self, reason: str) -> Optional[Deferred]: + def close(self, reason: str) -> Deferred[None] | None: """ (1) dump pending requests to disk if there is a disk queue (2) return the result of the dupefilter's ``close`` method @@ -260,7 +263,7 @@ class Scheduler(BaseScheduler): self.stats.inc_value("scheduler/enqueued", spider=self.spider) return True - def next_request(self) -> Optional[Request]: + def next_request(self) -> Request | None: """ Return a :class:`~scrapy.http.Request` object from the memory queue, falling back to the disk queue if the memory queue is empty. @@ -269,7 +272,7 @@ class Scheduler(BaseScheduler): Increment the appropriate stats, such as: ``scheduler/dequeued``, ``scheduler/dequeued/disk``, ``scheduler/dequeued/memory``. """ - request: Optional[Request] = self.mqs.pop() + 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) @@ -315,13 +318,15 @@ class Scheduler(BaseScheduler): def _mqpush(self, request: Request) -> None: self.mqs.push(request) - def _dqpop(self) -> Optional[Request]: + def _dqpop(self) -> Request | None: if self.dqs is not None: return self.dqs.pop() return None - def _mq(self): + def _mq(self) -> ScrapyPriorityQueue: """Create a new priority queue instance, with in-memory storage""" + assert self.crawler + assert self.pqclass return build_from_crawler( self.pqclass, self.crawler, @@ -329,9 +334,11 @@ class Scheduler(BaseScheduler): key="", ) - def _dq(self): + def _dq(self) -> ScrapyPriorityQueue: """Create a new priority queue instance, with disk storage""" + assert self.crawler assert self.dqdir + assert self.pqclass state = self._read_dqs_state(self.dqdir) q = build_from_crawler( self.pqclass, @@ -348,7 +355,7 @@ class Scheduler(BaseScheduler): ) return q - def _dqdir(self, jobdir: Optional[str]) -> Optional[str]: + def _dqdir(self, jobdir: str | None) -> str | None: """Return a folder name to keep disk queue state at""" if jobdir: dqdir = Path(jobdir, "requests.queue") @@ -357,13 +364,13 @@ class Scheduler(BaseScheduler): return str(dqdir) return None - def _read_dqs_state(self, dqdir: str) -> list: + def _read_dqs_state(self, dqdir: str) -> list[int]: path = Path(dqdir, "active.json") if not path.exists(): return [] with path.open(encoding="utf-8") as f: - return cast(list, json.load(f)) + return cast(list[int], json.load(f)) - def _write_dqs_state(self, dqdir: str, state: list) -> None: + def _write_dqs_state(self, dqdir: str, state: list[int]) -> None: with Path(dqdir, "active.json").open("w", encoding="utf-8") as f: json.dump(state, f) diff --git a/scrapy/core/scraper.py b/scrapy/core/scraper.py index 8fb16b8a9..83dad0c0b 100644 --- a/scrapy/core/scraper.py +++ b/scrapy/core/scraper.py @@ -1,23 +1,12 @@ """This module implements the Scraper component which parses responses and extracts information from them""" + from __future__ import annotations import logging from collections import deque -from typing import ( - TYPE_CHECKING, - Any, - AsyncGenerator, - AsyncIterable, - Deque, - Generator, - Iterable, - Optional, - Set, - Tuple, - Type, - Union, -) +from collections.abc import AsyncIterable, Iterator +from typing import TYPE_CHECKING, Any, TypeVar, Union, cast from itemadapter import is_item from twisted.internet.defer import Deferred, inlineCallbacks @@ -43,32 +32,37 @@ from scrapy.utils.misc import load_object, warn_on_generator_with_return_value from scrapy.utils.spider import iterate_spider_output if TYPE_CHECKING: + from collections.abc import Generator, Iterable + from scrapy.crawler import Crawler -QueueTuple = Tuple[Union[Response, Failure], Request, Deferred] - - logger = logging.getLogger(__name__) +_T = TypeVar("_T") +_ParallelResult = list[tuple[bool, Iterator[Any]]] +_HandleOutputDeferred = Deferred[Union[_ParallelResult, None]] +QueueTuple = tuple[Union[Response, Failure], Request, _HandleOutputDeferred] + + class Slot: """Scraper slot (one per running spider)""" MIN_RESPONSE_SIZE = 1024 def __init__(self, max_active_size: int = 5000000): - self.max_active_size = max_active_size - self.queue: Deque[QueueTuple] = deque() - self.active: Set[Request] = set() + self.max_active_size: int = max_active_size + self.queue: deque[QueueTuple] = deque() + self.active: set[Request] = set() self.active_size: int = 0 self.itemproc_size: int = 0 - self.closing: Optional[Deferred] = None + self.closing: Deferred[Spider] | None = None def add_response_request( - self, result: Union[Response, Failure], request: Request - ) -> Deferred: - deferred: Deferred = Deferred() + self, result: Response | Failure, request: Request + ) -> _HandleOutputDeferred: + deferred: _HandleOutputDeferred = Deferred() self.queue.append((result, request, deferred)) if isinstance(result, Response): self.active_size += max(len(result.body), self.MIN_RESPONSE_SIZE) @@ -81,9 +75,7 @@ class Slot: self.active.add(request) return response, request, deferred - def finish_response( - self, result: Union[Response, Failure], request: Request - ) -> None: + def finish_response(self, result: Response | Failure, request: Request) -> None: self.active.remove(request) if isinstance(result, Response): self.active_size -= max(len(result.body), self.MIN_RESPONSE_SIZE) @@ -99,11 +91,11 @@ class Slot: class Scraper: def __init__(self, crawler: Crawler) -> None: - self.slot: Optional[Slot] = None + self.slot: Slot | None = None self.spidermw: SpiderMiddlewareManager = SpiderMiddlewareManager.from_crawler( crawler ) - itemproc_cls: Type[ItemPipelineManager] = load_object( + itemproc_cls: type[ItemPipelineManager] = load_object( crawler.settings["ITEM_PROCESSOR"] ) self.itemproc: ItemPipelineManager = itemproc_cls.from_crawler(crawler) @@ -114,12 +106,12 @@ class Scraper: self.logformatter: LogFormatter = crawler.logformatter @inlineCallbacks - def open_spider(self, spider: Spider) -> Generator[Deferred, Any, None]: + def open_spider(self, spider: Spider) -> Generator[Deferred[Any], Any, None]: """Open the given spider for scraping and allocate resources for it""" self.slot = Slot(self.crawler.settings.getint("SCRAPER_SLOT_MAX_ACTIVE_SIZE")) yield self.itemproc.open_spider(spider) - def close_spider(self, spider: Spider) -> Deferred: + def close_spider(self, spider: Spider) -> Deferred[Spider]: """Close a spider being scraped and release its resources""" if self.slot is None: raise RuntimeError("Scraper slot not assigned") @@ -138,13 +130,13 @@ class Scraper: self.slot.closing.callback(spider) def enqueue_scrape( - self, result: Union[Response, Failure], request: Request, spider: Spider - ) -> Deferred: + self, result: Response | Failure, request: Request, spider: Spider + ) -> _HandleOutputDeferred: if self.slot is None: raise RuntimeError("Scraper slot not assigned") dfd = self.slot.add_response_request(result, request) - def finish_scraping(_: Any) -> Any: + def finish_scraping(_: _T) -> _T: assert self.slot is not None self.slot.finish_response(result, request) self._check_if_closing(spider) @@ -170,8 +162,8 @@ class Scraper: self._scrape(response, request, spider).chainDeferred(deferred) def _scrape( - self, result: Union[Response, Failure], request: Request, spider: Spider - ) -> Deferred: + self, result: Response | Failure, request: Request, spider: Spider + ) -> _HandleOutputDeferred: """ Handle the downloaded response or failure through the spider callback/errback """ @@ -179,30 +171,35 @@ class Scraper: raise TypeError( f"Incorrect type: expected Response or Failure, got {type(result)}: {result!r}" ) - dfd = self._scrape2( + dfd: Deferred[Iterable[Any] | AsyncIterable[Any]] = self._scrape2( result, request, spider ) # returns spider's processed output dfd.addErrback(self.handle_spider_error, request, result, spider) - dfd.addCallback(self.handle_spider_output, request, result, spider) - return dfd + dfd2: _HandleOutputDeferred = dfd.addCallback( + self.handle_spider_output, request, cast(Response, result), spider + ) + return dfd2 def _scrape2( - self, result: Union[Response, Failure], request: Request, spider: Spider - ) -> Deferred: + self, result: Response | Failure, request: Request, spider: Spider + ) -> Deferred[Iterable[Any] | AsyncIterable[Any]]: """ Handle the different cases of request's result been a Response or a Failure """ if isinstance(result, Response): - return self.spidermw.scrape_response( + # Deferreds are invariant so Mutable*Chain isn't matched to *Iterable + return self.spidermw.scrape_response( # type: ignore[return-value] self.call_spider, result, request, spider ) # else result is a Failure dfd = self.call_spider(result, request, spider) - return dfd.addErrback(self._log_download_errors, result, request, spider) + dfd.addErrback(self._log_download_errors, result, request, spider) + return dfd def call_spider( - self, result: Union[Response, Failure], request: Request, spider: Spider - ) -> Deferred: + self, result: Response | Failure, request: Request, spider: Spider + ) -> Deferred[Iterable[Any] | AsyncIterable[Any]]: + dfd: Deferred[Any] if isinstance(result, Response): if getattr(result, "request", None) is None: result.request = request @@ -220,13 +217,16 @@ class Scraper: if request.errback: warn_on_generator_with_return_value(spider, request.errback) dfd.addErrback(request.errback) - return dfd.addCallback(iterate_spider_output) + dfd2: Deferred[Iterable[Any] | AsyncIterable[Any]] = dfd.addCallback( + iterate_spider_output + ) + return dfd2 def handle_spider_error( self, _failure: Failure, request: Request, - response: Union[Response, Failure], + response: Response | Failure, spider: Spider, ) -> None: exc = _failure.value @@ -253,14 +253,15 @@ class Scraper: def handle_spider_output( self, - result: Union[Iterable, AsyncIterable], + result: Iterable[_T] | AsyncIterable[_T], request: Request, - response: Union[Response, Failure], + response: Response, spider: Spider, - ) -> Deferred: + ) -> _HandleOutputDeferred: if not result: return defer_succeed(None) - it: Union[Generator, AsyncGenerator] + it: Iterable[_T] | AsyncIterable[_T] + dfd: Deferred[_ParallelResult] if isinstance(result, AsyncIterable): it = aiter_errback( result, self.handle_spider_error, request, response, spider @@ -285,23 +286,20 @@ class Scraper: response, spider, ) - return dfd + # returning Deferred[_ParallelResult] instead of Deferred[Union[_ParallelResult, None]] + return dfd # type: ignore[return-value] def _process_spidermw_output( self, output: Any, request: Request, response: Response, spider: Spider - ) -> Optional[Deferred]: + ) -> Deferred[Any] | None: """Process each Request/Item (given in the output parameter) returned from the given spider """ - assert self.slot is not None # typing if isinstance(output, Request): assert self.crawler.engine is not None # typing self.crawler.engine.crawl(request=output) elif is_item(output): - self.slot.itemproc_size += 1 - dfd = self.itemproc.process_item(output, spider) - dfd.addBoth(self._itemproc_finished, output, response, spider) - return dfd + return self.start_itemproc(output, response=response) elif output is None: pass else: @@ -313,13 +311,26 @@ class Scraper: ) return None + def start_itemproc(self, item: Any, *, response: Response | None) -> Deferred[Any]: + """Send *item* to the item pipelines for processing. + + *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``. + """ + assert self.slot is not None # typing + assert self.crawler.spider is not None # typing + self.slot.itemproc_size += 1 + dfd = self.itemproc.process_item(item, self.crawler.spider) + dfd.addBoth(self._itemproc_finished, item, response, self.crawler.spider) + return dfd + def _log_download_errors( self, spider_failure: Failure, download_failure: Failure, request: Request, spider: Spider, - ) -> Union[Failure, None]: + ) -> Failure | None: """Log and silence errors that come from the engine (typically download errors that got propagated thru here). @@ -353,8 +364,8 @@ class Scraper: return None def _itemproc_finished( - self, output: Any, item: Any, response: Response, spider: Spider - ) -> Deferred: + self, output: Any, item: Any, response: Response | None, spider: Spider + ) -> Deferred[Any]: """ItemProcessor finished for the given ``item`` and returned ``output``""" assert self.slot is not None # typing self.slot.itemproc_size -= 1 diff --git a/scrapy/core/spidermw.py b/scrapy/core/spidermw.py index 031a0be36..f7947d35d 100644 --- a/scrapy/core/spidermw.py +++ b/scrapy/core/spidermw.py @@ -3,22 +3,14 @@ Spider Middleware manager See documentation in docs/topics/spider-middleware.rst """ + +from __future__ import annotations + import logging +from collections.abc import AsyncIterable, Callable, Iterable from inspect import isasyncgenfunction, iscoroutine from itertools import islice -from typing import ( - Any, - AsyncGenerator, - AsyncIterable, - Callable, - Generator, - Iterable, - List, - Optional, - Tuple, - Union, - cast, -) +from typing import TYPE_CHECKING, Any, TypeVar, Union, cast from twisted.internet.defer import Deferred, inlineCallbacks from twisted.python.failure import Failure @@ -27,7 +19,6 @@ from scrapy import Request, Spider from scrapy.exceptions import _InvalidOutput from scrapy.http import Response from scrapy.middleware import MiddlewareManager -from scrapy.settings import BaseSettings from scrapy.utils.asyncgen import as_async_generator, collect_asyncgen from scrapy.utils.conf import build_component_list from scrapy.utils.defer import ( @@ -38,10 +29,19 @@ from scrapy.utils.defer import ( ) from scrapy.utils.python import MutableAsyncChain, MutableChain +if TYPE_CHECKING: + from collections.abc import Generator + + from scrapy.settings import BaseSettings + + logger = logging.getLogger(__name__) -ScrapeFunc = Callable[[Union[Response, Failure], Request, Spider], Any] +_T = TypeVar("_T") +ScrapeFunc = Callable[ + [Union[Response, Failure], Request, Spider], Union[Iterable[_T], AsyncIterable[_T]] +] def _isiterable(o: Any) -> bool: @@ -56,7 +56,7 @@ class SpiderMiddlewareManager(MiddlewareManager): self.downgrade_warning_done = False @classmethod - def _get_mwlist_from_settings(cls, settings: BaseSettings) -> List[Any]: + def _get_mwlist_from_settings(cls, settings: BaseSettings) -> list[Any]: return build_component_list(settings.getwithbase("SPIDER_MIDDLEWARES")) def _add_middleware(self, mw: Any) -> None: @@ -72,11 +72,11 @@ class SpiderMiddlewareManager(MiddlewareManager): def _process_spider_input( self, - scrape_func: ScrapeFunc, + scrape_func: ScrapeFunc[_T], response: Response, request: Request, spider: Spider, - ) -> Any: + ) -> Iterable[_T] | AsyncIterable[_T]: for method in self.methods["process_spider_input"]: method = cast(Callable, method) try: @@ -97,31 +97,39 @@ class SpiderMiddlewareManager(MiddlewareManager): self, response: Response, spider: Spider, - iterable: Union[Iterable, AsyncIterable], + iterable: Iterable[_T] | AsyncIterable[_T], exception_processor_index: int, - recover_to: Union[MutableChain, MutableAsyncChain], - ) -> Union[Generator, AsyncGenerator]: - def process_sync(iterable: Iterable) -> Generator: + recover_to: MutableChain[_T] | MutableAsyncChain[_T], + ) -> Iterable[_T] | AsyncIterable[_T]: + def process_sync(iterable: Iterable[_T]) -> Iterable[_T]: try: yield from iterable except Exception as ex: - exception_result = self._process_spider_exception( - response, spider, Failure(ex), exception_processor_index + exception_result = cast( + Union[Failure, MutableChain[_T]], + self._process_spider_exception( + response, spider, Failure(ex), exception_processor_index + ), ) if isinstance(exception_result, Failure): raise + assert isinstance(recover_to, MutableChain) recover_to.extend(exception_result) - async def process_async(iterable: AsyncIterable) -> AsyncGenerator: + async def process_async(iterable: AsyncIterable[_T]) -> AsyncIterable[_T]: try: async for r in iterable: yield r except Exception as ex: - exception_result = self._process_spider_exception( - response, spider, Failure(ex), exception_processor_index + exception_result = cast( + Union[Failure, MutableAsyncChain[_T]], + self._process_spider_exception( + response, spider, Failure(ex), exception_processor_index + ), ) if isinstance(exception_result, Failure): raise + assert isinstance(recover_to, MutableAsyncChain) recover_to.extend(exception_result) if isinstance(iterable, AsyncIterable): @@ -134,7 +142,7 @@ class SpiderMiddlewareManager(MiddlewareManager): spider: Spider, _failure: Failure, start_index: int = 0, - ) -> Union[Failure, MutableChain]: + ) -> Failure | MutableChain[_T] | MutableAsyncChain[_T]: exception = _failure.value # don't handle _InvalidOutput exception if isinstance(exception, _InvalidOutput): @@ -150,14 +158,18 @@ class SpiderMiddlewareManager(MiddlewareManager): if _isiterable(result): # stop exception handling by handing control over to the # process_spider_output chain if an iterable has been returned - dfd: Deferred = self._process_spider_output( - response, spider, result, method_index + 1 + dfd: Deferred[MutableChain[_T] | MutableAsyncChain[_T]] = ( + self._process_spider_output( + response, spider, 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(MutableChain, dfd.result) + return cast( + Union[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 {method.__qualname__} cannot be downgraded" @@ -180,12 +192,12 @@ class SpiderMiddlewareManager(MiddlewareManager): self, response: Response, spider: Spider, - result: Union[Iterable, AsyncIterable], + result: Iterable[_T] | AsyncIterable[_T], start_index: int = 0, - ) -> Generator[Deferred, Any, Union[MutableChain, MutableAsyncChain]]: + ) -> Generator[Deferred[Any], Any, MutableChain[_T] | MutableAsyncChain[_T]]: # items in this iterable do not need to go through the process_spider_output # chain, they went through it already from the process_spider_exception method - recovered: Union[MutableChain, MutableAsyncChain] + recovered: MutableChain[_T] | MutableAsyncChain[_T] last_result_is_async = isinstance(result, AsyncIterable) if last_result_is_async: recovered = MutableAsyncChain() @@ -236,8 +248,10 @@ class SpiderMiddlewareManager(MiddlewareManager): # might fail directly if the output value is not a generator result = method(response=response, result=result, spider=spider) except Exception as ex: - exception_result = self._process_spider_exception( - response, spider, Failure(ex), method_index + 1 + exception_result: Failure | MutableChain[_T] | MutableAsyncChain[_T] = ( + self._process_spider_exception( + response, spider, Failure(ex), method_index + 1 + ) ) if isinstance(exception_result, Failure): raise @@ -266,16 +280,22 @@ class SpiderMiddlewareManager(MiddlewareManager): return MutableChain(result, recovered) # type: ignore[arg-type] async def _process_callback_output( - self, response: Response, spider: Spider, result: Union[Iterable, AsyncIterable] - ) -> Union[MutableChain, MutableAsyncChain]: - recovered: Union[MutableChain, MutableAsyncChain] + self, + response: Response, + spider: Spider, + result: Iterable[_T] | AsyncIterable[_T], + ) -> MutableChain[_T] | MutableAsyncChain[_T]: + recovered: MutableChain[_T] | MutableAsyncChain[_T] if isinstance(result, AsyncIterable): recovered = MutableAsyncChain() else: recovered = MutableChain() result = self._evaluate_iterable(response, spider, result, 0, recovered) result = await maybe_deferred_to_future( - self._process_spider_output(response, spider, result) + cast( + "Deferred[Iterable[_T] | AsyncIterable[_T]]", + self._process_spider_output(response, spider, result), + ) ) if isinstance(result, AsyncIterable): return MutableAsyncChain(result, recovered) @@ -286,41 +306,43 @@ class SpiderMiddlewareManager(MiddlewareManager): def scrape_response( self, - scrape_func: ScrapeFunc, + scrape_func: ScrapeFunc[_T], response: Response, request: Request, spider: Spider, - ) -> Deferred: + ) -> Deferred[MutableChain[_T] | MutableAsyncChain[_T]]: async def process_callback_output( - result: Union[Iterable, AsyncIterable] - ) -> Union[MutableChain, MutableAsyncChain]: + result: Iterable[_T] | AsyncIterable[_T], + ) -> MutableChain[_T] | MutableAsyncChain[_T]: return await self._process_callback_output(response, spider, result) - def process_spider_exception(_failure: Failure) -> Union[Failure, MutableChain]: + def process_spider_exception( + _failure: Failure, + ) -> Failure | MutableChain[_T] | MutableAsyncChain[_T]: return self._process_spider_exception(response, spider, _failure) - dfd = mustbe_deferred( + dfd: Deferred[Iterable[_T] | AsyncIterable[_T]] = mustbe_deferred( self._process_spider_input, scrape_func, response, request, spider ) - dfd.addCallbacks( - callback=deferred_f_from_coro_f(process_callback_output), - errback=process_spider_exception, + dfd2: Deferred[MutableChain[_T] | MutableAsyncChain[_T]] = dfd.addCallback( + deferred_f_from_coro_f(process_callback_output) ) - return dfd + dfd2.addErrback(process_spider_exception) + return dfd2 def process_start_requests( self, start_requests: Iterable[Request], spider: Spider - ) -> Deferred: + ) -> Deferred[Iterable[Request]]: return self._process_chain("process_start_requests", start_requests, spider) # This method is only needed until _async compatibility methods are removed. @staticmethod def _get_async_method_pair( mw: Any, methodname: str - ) -> Union[None, Callable, Tuple[Callable, Callable]]: - normal_method: Optional[Callable] = getattr(mw, methodname, None) + ) -> Callable | tuple[Callable, Callable] | None: + normal_method: Callable | None = getattr(mw, methodname, None) methodname_async = methodname + "_async" - async_method: Optional[Callable] = getattr(mw, methodname_async, None) + async_method: Callable | None = getattr(mw, methodname_async, None) if not async_method: return normal_method if not normal_method: diff --git a/scrapy/crawler.py b/scrapy/crawler.py index 1db9ace28..3e5657d22 100644 --- a/scrapy/crawler.py +++ b/scrapy/crawler.py @@ -4,7 +4,7 @@ import logging import pprint import signal import warnings -from typing import TYPE_CHECKING, Any, Dict, Generator, Optional, Set, Type, Union, cast +from typing import TYPE_CHECKING, Any, TypeVar, cast from twisted.internet.defer import ( Deferred, @@ -12,13 +12,6 @@ from twisted.internet.defer import ( inlineCallbacks, maybeDeferred, ) - -try: - # zope >= 5.0 only supports MultipleInvalid - from zope.interface.exceptions import MultipleInvalid -except ImportError: - MultipleInvalid = None - from zope.interface.verify import verifyClass from scrapy import Spider, signals @@ -49,17 +42,22 @@ from scrapy.utils.reactor import ( ) if TYPE_CHECKING: + from collections.abc import Generator, Iterable + + from scrapy.spiderloader import SpiderLoader from scrapy.utils.request import RequestFingerprinter logger = logging.getLogger(__name__) +_T = TypeVar("_T") + class Crawler: def __init__( self, - spidercls: Type[Spider], - settings: Union[None, Dict[str, Any], Settings] = None, + spidercls: type[Spider], + settings: dict[str, Any] | Settings | None = None, init_reactor: bool = False, ): if isinstance(spidercls, Spider): @@ -68,7 +66,7 @@ class Crawler: if isinstance(settings, dict) or settings is None: settings = Settings(settings) - self.spidercls: Type[Spider] = spidercls + self.spidercls: type[Spider] = spidercls self.settings: Settings = settings.copy() self.spidercls.update_settings(self.settings) self._update_root_log_handler() @@ -80,12 +78,12 @@ class Crawler: self.crawling: bool = False self._started: bool = False - self.extensions: Optional[ExtensionManager] = None - self.stats: Optional[StatsCollector] = None - self.logformatter: Optional[LogFormatter] = None - self.request_fingerprinter: Optional[RequestFingerprinter] = None - self.spider: Optional[Spider] = None - self.engine: Optional[ExecutionEngine] = None + self.extensions: ExtensionManager | None = None + self.stats: StatsCollector | None = None + self.logformatter: LogFormatter | None = None + self.request_fingerprinter: RequestFingerprinter | None = None + self.spider: Spider | None = None + self.engine: ExecutionEngine | None = None def _update_root_log_handler(self) -> None: if get_scrapy_root_handler() is not None: @@ -106,7 +104,7 @@ class Crawler: 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"]) + lf_cls: type[LogFormatter] = load_object(self.settings["LOG_FORMATTER"]) self.logformatter = lf_cls.from_crawler(self) self.request_fingerprinter = build_from_crawler( @@ -129,6 +127,8 @@ class Crawler: if is_asyncio_reactor_installed() and event_loop: verify_installed_asyncio_event_loop(event_loop) + log_reactor_info() + self.extensions = ExtensionManager.from_crawler(self) self.settings.freeze() @@ -138,7 +138,7 @@ class Crawler: ) @inlineCallbacks - def crawl(self, *args: Any, **kwargs: Any) -> Generator[Deferred, Any, None]: + def crawl(self, *args: Any, **kwargs: Any) -> Generator[Deferred[Any], Any, None]: if self.crawling: raise RuntimeError("Crawling already taking place") if self._started: @@ -170,7 +170,7 @@ class Crawler: return ExecutionEngine(self, lambda _: self.stop()) @inlineCallbacks - def stop(self) -> Generator[Deferred, Any, None]: + def stop(self) -> Generator[Deferred[Any], Any, None]: """Starts a graceful stop of the crawler and returns a deferred that is fired when the crawler is stopped.""" if self.crawling: @@ -179,16 +179,18 @@ class Crawler: yield maybeDeferred(self.engine.stop) @staticmethod - def _get_component(component_class, components): + def _get_component( + component_class: type[_T], components: Iterable[Any] + ) -> _T | None: for component in components: if isinstance(component, component_class): return component return None - def get_addon(self, cls): + def get_addon(self, cls: type[_T]) -> _T | None: return self._get_component(cls, self.addons.addons) - def get_downloader_middleware(self, cls): + def get_downloader_middleware(self, cls: type[_T]) -> _T | None: if not self.engine: raise RuntimeError( "Crawler.get_downloader_middleware() can only be called after " @@ -196,7 +198,7 @@ class Crawler: ) return self._get_component(cls, self.engine.downloader.middleware.middlewares) - def get_extension(self, cls): + def get_extension(self, cls: type[_T]) -> _T | None: if not self.extensions: raise RuntimeError( "Crawler.get_extension() can only be called after the " @@ -204,7 +206,7 @@ class Crawler: ) return self._get_component(cls, self.extensions.middlewares) - def get_item_pipeline(self, cls): + def get_item_pipeline(self, cls: type[_T]) -> _T | None: if not self.engine: raise RuntimeError( "Crawler.get_item_pipeline() can only be called after the " @@ -212,7 +214,7 @@ class Crawler: ) return self._get_component(cls, self.engine.scraper.itemproc.middlewares) - def get_spider_middleware(self, cls): + def get_spider_middleware(self, cls: type[_T]) -> _T | None: if not self.engine: raise RuntimeError( "Crawler.get_spider_middleware() can only be called after the " @@ -241,28 +243,28 @@ class CrawlerRunner: ) @staticmethod - def _get_spider_loader(settings: BaseSettings): + def _get_spider_loader(settings: BaseSettings) -> SpiderLoader: """Get SpiderLoader instance from settings""" cls_path = settings.get("SPIDER_LOADER_CLASS") loader_cls = load_object(cls_path) verifyClass(ISpiderLoader, loader_cls) - return loader_cls.from_settings(settings.frozencopy()) + return cast("SpiderLoader", loader_cls.from_settings(settings.frozencopy())) - def __init__(self, settings: Union[Dict[str, Any], Settings, None] = None): + def __init__(self, settings: dict[str, Any] | Settings | None = None): if isinstance(settings, dict) or settings is None: settings = Settings(settings) - self.settings = settings - self.spider_loader = self._get_spider_loader(settings) - self._crawlers: Set[Crawler] = set() - self._active: Set[Deferred] = set() + self.settings: Settings = settings + self.spider_loader: SpiderLoader = self._get_spider_loader(settings) + self._crawlers: set[Crawler] = set() + self._active: set[Deferred[None]] = set() self.bootstrap_failed = False def crawl( self, - crawler_or_spidercls: Union[Type[Spider], str, Crawler], + crawler_or_spidercls: type[Spider] | str | Crawler, *args: Any, **kwargs: Any, - ) -> Deferred: + ) -> Deferred[None]: """ Run a crawler with the provided arguments. @@ -292,12 +294,12 @@ class CrawlerRunner: crawler = self.create_crawler(crawler_or_spidercls) return self._crawl(crawler, *args, **kwargs) - def _crawl(self, crawler: Crawler, *args: Any, **kwargs: Any) -> Deferred: + def _crawl(self, crawler: Crawler, *args: Any, **kwargs: Any) -> Deferred[None]: self.crawlers.add(crawler) d = crawler.crawl(*args, **kwargs) self._active.add(d) - def _done(result: Any) -> Any: + def _done(result: _T) -> _T: self.crawlers.discard(crawler) self._active.discard(d) self.bootstrap_failed |= not getattr(crawler, "spider", None) @@ -306,7 +308,7 @@ class CrawlerRunner: return d.addBoth(_done) def create_crawler( - self, crawler_or_spidercls: Union[Type[Spider], str, Crawler] + self, crawler_or_spidercls: type[Spider] | str | Crawler ) -> Crawler: """ Return a :class:`~scrapy.crawler.Crawler` object. @@ -327,13 +329,12 @@ class CrawlerRunner: return crawler_or_spidercls return self._create_crawler(crawler_or_spidercls) - def _create_crawler(self, spidercls: Union[str, Type[Spider]]) -> Crawler: + def _create_crawler(self, spidercls: str | type[Spider]) -> Crawler: if isinstance(spidercls, str): spidercls = self.spider_loader.load(spidercls) - # temporary cast until self.spider_loader is typed - return Crawler(cast(Type[Spider], spidercls), self.settings) + return Crawler(spidercls, self.settings) - def stop(self) -> Deferred: + def stop(self) -> Deferred[Any]: """ Stops simultaneously all the crawling jobs taking place. @@ -342,7 +343,7 @@ class CrawlerRunner: return DeferredList([c.stop() for c in list(self.crawlers)]) @inlineCallbacks - def join(self) -> Generator[Deferred, Any, None]: + def join(self) -> Generator[Deferred[Any], Any, None]: """ join() @@ -379,13 +380,13 @@ class CrawlerProcess(CrawlerRunner): def __init__( self, - settings: Union[Dict[str, Any], Settings, None] = None, + settings: dict[str, Any] | Settings | None = None, install_root_handler: bool = True, ): super().__init__(settings) configure_logging(self.settings, install_root_handler) log_scrapy_info(self.settings) - self._initialized_reactor = False + self._initialized_reactor: bool = False def _signal_shutdown(self, signum: int, _: Any) -> None: from twisted.internet import reactor @@ -408,15 +409,13 @@ class CrawlerProcess(CrawlerRunner): ) reactor.callFromThread(self._stop_reactor) - def _create_crawler(self, spidercls: Union[Type[Spider], str]) -> Crawler: + def _create_crawler(self, spidercls: type[Spider] | str) -> Crawler: if isinstance(spidercls, str): spidercls = self.spider_loader.load(spidercls) init_reactor = not self._initialized_reactor self._initialized_reactor = True # temporary cast until self.spider_loader is typed - return Crawler( - cast(Type[Spider], spidercls), self.settings, init_reactor=init_reactor - ) + return Crawler(spidercls, self.settings, init_reactor=init_reactor) def start( self, stop_after_crawl: bool = True, install_signal_handlers: bool = True @@ -445,7 +444,9 @@ class CrawlerProcess(CrawlerRunner): d.addBoth(self._stop_reactor) resolver_class = load_object(self.settings["DNS_RESOLVER"]) - resolver = build_from_crawler(resolver_class, self, reactor=reactor) + # We pass self, which is CrawlerProcess, instead of Crawler here, + # which works because the default resolvers only use crawler.settings. + resolver = build_from_crawler(resolver_class, self, reactor=reactor) # type: ignore[arg-type] resolver.install_on_reactor() tp = reactor.getThreadPool() tp.adjustPoolsize(maxthreads=self.settings.getint("REACTOR_THREADPOOL_MAXSIZE")) @@ -456,7 +457,7 @@ class CrawlerProcess(CrawlerRunner): ) reactor.run(installSignalHandlers=install_signal_handlers) # blocking call - def _graceful_stop_reactor(self) -> Deferred: + def _graceful_stop_reactor(self) -> Deferred[Any]: d = self.stop() d.addBoth(self._stop_reactor) return d diff --git a/scrapy/downloadermiddlewares/ajaxcrawl.py b/scrapy/downloadermiddlewares/ajaxcrawl.py index 0e757e4be..b813baf86 100644 --- a/scrapy/downloadermiddlewares/ajaxcrawl.py +++ b/scrapy/downloadermiddlewares/ajaxcrawl.py @@ -2,20 +2,22 @@ from __future__ import annotations import logging import re -from typing import TYPE_CHECKING, Union +from typing import TYPE_CHECKING from w3lib import html -from scrapy import Request, Spider -from scrapy.crawler import Crawler from scrapy.exceptions import NotConfigured from scrapy.http import HtmlResponse, Response -from scrapy.settings import BaseSettings if TYPE_CHECKING: # typing.Self requires Python 3.11 from typing_extensions import Self + from scrapy import Request, Spider + from scrapy.crawler import Crawler + from scrapy.settings import BaseSettings + + logger = logging.getLogger(__name__) @@ -41,7 +43,7 @@ class AjaxCrawlMiddleware: def process_response( self, request: Request, response: Response, spider: Spider - ) -> Union[Request, Response]: + ) -> Request | Response: if not isinstance(response, HtmlResponse) or response.status != 200: return response diff --git a/scrapy/downloadermiddlewares/cookies.py b/scrapy/downloadermiddlewares/cookies.py index cc70fe366..2aa1a8a96 100644 --- a/scrapy/downloadermiddlewares/cookies.py +++ b/scrapy/downloadermiddlewares/cookies.py @@ -2,22 +2,11 @@ from __future__ import annotations import logging from collections import defaultdict -from http.cookiejar import Cookie -from typing import ( - TYPE_CHECKING, - Any, - DefaultDict, - Dict, - Iterable, - Optional, - Sequence, - Union, -) +from typing import TYPE_CHECKING, Any from tldextract import TLDExtract -from scrapy import Request, Spider, signals -from scrapy.crawler import Crawler +from scrapy import signals from scrapy.exceptions import NotConfigured from scrapy.http import Response from scrapy.http.cookies import CookieJar @@ -25,14 +14,22 @@ from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.python import to_unicode if TYPE_CHECKING: + from collections.abc import Iterable, Sequence + from http.cookiejar import Cookie + # typing.Self requires Python 3.11 from typing_extensions import Self + from scrapy import Request, Spider + from scrapy.crawler import Crawler + from scrapy.http.request import VerboseCookie + logger = logging.getLogger(__name__) _split_domain = TLDExtract(include_psl_private_domains=True) +_UNSET = object() def _is_public_domain(domain: str) -> bool: @@ -44,7 +41,7 @@ class CookiesMiddleware: """This middleware enables working with sites that need cookies""" def __init__(self, debug: bool = False): - self.jars: DefaultDict[Any, CookieJar] = defaultdict(CookieJar) + self.jars: defaultdict[Any, CookieJar] = defaultdict(CookieJar) self.debug: bool = debug @classmethod @@ -79,7 +76,7 @@ class CookiesMiddleware: def process_request( self, request: Request, spider: Spider - ) -> Union[Request, Response, None]: + ) -> Request | Response | None: if request.meta.get("dont_merge_cookies", False): return None @@ -96,7 +93,7 @@ class CookiesMiddleware: def process_response( self, request: Request, response: Response, spider: Spider - ) -> Union[Request, Response]: + ) -> Request | Response: if request.meta.get("dont_merge_cookies", False): return response @@ -132,12 +129,13 @@ class CookiesMiddleware: msg = f"Received cookies from: {response}\n{cookies}" logger.debug(msg, extra={"spider": spider}) - def _format_cookie(self, cookie: Dict[str, Any], request: Request) -> Optional[str]: + def _format_cookie(self, cookie: VerboseCookie, request: Request) -> str | None: """ Given a dict consisting of cookie components, return its string representation. Decode from bytes if necessary. """ decoded = {} + flags = set() for key in ("name", "value", "path", "domain"): if cookie.get(key) is None: if key in ("name", "value"): @@ -145,22 +143,29 @@ class CookiesMiddleware: logger.warning(msg) return None continue - if isinstance(cookie[key], (bool, float, int, str)): - decoded[key] = str(cookie[key]) + # https://github.com/python/mypy/issues/7178, https://github.com/python/mypy/issues/9168 + if isinstance(cookie[key], (bool, float, int, str)): # type: ignore[literal-required] + decoded[key] = str(cookie[key]) # type: ignore[literal-required] else: try: - decoded[key] = cookie[key].decode("utf8") + decoded[key] = cookie[key].decode("utf8") # type: ignore[literal-required] except UnicodeDecodeError: logger.warning( "Non UTF-8 encoded cookie found in request %s: %s", request, cookie, ) - decoded[key] = cookie[key].decode("latin1", errors="replace") - + decoded[key] = cookie[key].decode("latin1", errors="replace") # type: ignore[literal-required] + for flag in ("secure",): + value = cookie.get(flag, _UNSET) + if value is _UNSET or not value: + continue + flags.add(flag) cookie_str = f"{decoded.pop('name')}={decoded.pop('value')}" for key, value in decoded.items(): # path, domain cookie_str += f"; {key.capitalize()}={value}" + for flag in flags: # secure + cookie_str += f"; {flag.capitalize()}" return cookie_str def _get_request_cookies( @@ -171,11 +176,13 @@ class CookiesMiddleware: """ if not request.cookies: return [] - cookies: Iterable[Dict[str, Any]] + cookies: Iterable[VerboseCookie] if isinstance(request.cookies, dict): - cookies = ({"name": k, "value": v} for k, v in request.cookies.items()) + cookies = tuple({"name": k, "value": v} for k, v in request.cookies.items()) else: cookies = request.cookies + for cookie in cookies: + cookie.setdefault("secure", urlparse_cached(request).scheme == "https") formatted = filter(None, (self._format_cookie(c, request) for c in cookies)) response = Response(request.url, headers={"Set-Cookie": formatted}) return jar.make_cookies(response, request) diff --git a/scrapy/downloadermiddlewares/defaultheaders.py b/scrapy/downloadermiddlewares/defaultheaders.py index 8aec37cf1..d58b4490b 100644 --- a/scrapy/downloadermiddlewares/defaultheaders.py +++ b/scrapy/downloadermiddlewares/defaultheaders.py @@ -3,23 +3,27 @@ DefaultHeaders downloader middleware See documentation in docs/topics/downloader-middleware.rst """ + from __future__ import annotations -from typing import TYPE_CHECKING, Iterable, Tuple, Union +from typing import TYPE_CHECKING -from scrapy import Request, Spider -from scrapy.crawler import Crawler -from scrapy.http import Response from scrapy.utils.python import without_none_values if TYPE_CHECKING: + from collections.abc import Iterable + # typing.Self requires Python 3.11 from typing_extensions import Self + from scrapy import Request, Spider + from scrapy.crawler import Crawler + from scrapy.http import Response + class DefaultHeadersMiddleware: - def __init__(self, headers: Iterable[Tuple[str, str]]): - self._headers: Iterable[Tuple[str, str]] = headers + def __init__(self, headers: Iterable[tuple[str, str]]): + self._headers: Iterable[tuple[str, str]] = headers @classmethod def from_crawler(cls, crawler: Crawler) -> Self: @@ -28,7 +32,7 @@ class DefaultHeadersMiddleware: def process_request( self, request: Request, spider: Spider - ) -> Union[Request, Response, None]: + ) -> Request | Response | None: for k, v in self._headers: request.headers.setdefault(k, v) return None diff --git a/scrapy/downloadermiddlewares/downloadtimeout.py b/scrapy/downloadermiddlewares/downloadtimeout.py index 1c904c05b..28456c697 100644 --- a/scrapy/downloadermiddlewares/downloadtimeout.py +++ b/scrapy/downloadermiddlewares/downloadtimeout.py @@ -3,18 +3,20 @@ Download timeout middleware See documentation in docs/topics/downloader-middleware.rst """ + from __future__ import annotations -from typing import TYPE_CHECKING, Union +from typing import TYPE_CHECKING from scrapy import Request, Spider, signals -from scrapy.crawler import Crawler -from scrapy.http import Response if TYPE_CHECKING: # typing.Self requires Python 3.11 from typing_extensions import Self + from scrapy.crawler import Crawler + from scrapy.http import Response + class DownloadTimeoutMiddleware: def __init__(self, timeout: float = 180): @@ -31,7 +33,7 @@ class DownloadTimeoutMiddleware: def process_request( self, request: Request, spider: Spider - ) -> Union[Request, Response, None]: + ) -> Request | Response | None: if self._timeout: request.meta.setdefault("download_timeout", self._timeout) return None diff --git a/scrapy/downloadermiddlewares/httpauth.py b/scrapy/downloadermiddlewares/httpauth.py index 63490a37a..b74140ee1 100644 --- a/scrapy/downloadermiddlewares/httpauth.py +++ b/scrapy/downloadermiddlewares/httpauth.py @@ -6,19 +6,20 @@ See documentation in docs/topics/downloader-middleware.rst from __future__ import annotations -from typing import TYPE_CHECKING, Union +from typing import TYPE_CHECKING from w3lib.http import basic_auth_header from scrapy import Request, Spider, signals -from scrapy.crawler import Crawler -from scrapy.http import Response from scrapy.utils.url import url_is_from_any_domain if TYPE_CHECKING: # typing.Self requires Python 3.11 from typing_extensions import Self + from scrapy.crawler import Crawler + from scrapy.http import Response + class HttpAuthMiddleware: """Set Basic HTTP Authorization header @@ -39,7 +40,7 @@ class HttpAuthMiddleware: def process_request( self, request: Request, spider: Spider - ) -> Union[Request, Response, None]: + ) -> Request | Response | None: auth = getattr(self, "auth", None) if auth and b"Authorization" not in request.headers: if not self.domain or url_is_from_any_domain(request.url, [self.domain]): diff --git a/scrapy/downloadermiddlewares/httpcache.py b/scrapy/downloadermiddlewares/httpcache.py index 971473403..3892dba23 100644 --- a/scrapy/downloadermiddlewares/httpcache.py +++ b/scrapy/downloadermiddlewares/httpcache.py @@ -1,7 +1,7 @@ from __future__ import annotations from email.utils import formatdate -from typing import TYPE_CHECKING, Optional, Union +from typing import TYPE_CHECKING from twisted.internet import defer from twisted.internet.error import ( @@ -16,19 +16,20 @@ from twisted.internet.error import ( from twisted.web.client import ResponseFailed from scrapy import signals -from scrapy.crawler import Crawler from scrapy.exceptions import IgnoreRequest, NotConfigured -from scrapy.http.request import Request -from scrapy.http.response import Response -from scrapy.settings import Settings -from scrapy.spiders import Spider -from scrapy.statscollectors import StatsCollector from scrapy.utils.misc import load_object if TYPE_CHECKING: # typing.Self requires Python 3.11 from typing_extensions import Self + from scrapy.crawler import Crawler + from scrapy.http.request import Request + from scrapy.http.response import Response + from scrapy.settings import Settings + from scrapy.spiders import Spider + from scrapy.statscollectors import StatsCollector + class HttpCacheMiddleware: DOWNLOAD_EXCEPTIONS = ( @@ -68,7 +69,7 @@ class HttpCacheMiddleware: def process_request( self, request: Request, spider: Spider - ) -> Union[Request, Response, None]: + ) -> Request | Response | None: if request.meta.get("dont_cache", False): return None @@ -78,7 +79,7 @@ class HttpCacheMiddleware: return None # Look for cached response and check if expired - cachedresponse: Optional[Response] = self.storage.retrieve_response( + cachedresponse: Response | None = self.storage.retrieve_response( spider, request ) if cachedresponse is None: @@ -102,7 +103,7 @@ class HttpCacheMiddleware: def process_response( self, request: Request, response: Response, spider: Spider - ) -> Union[Request, Response]: + ) -> Request | Response: if request.meta.get("dont_cache", False): return response @@ -117,7 +118,7 @@ class HttpCacheMiddleware: response.headers["Date"] = formatdate(usegmt=True) # Do not validate first-hand responses - cachedresponse: Optional[Response] = request.meta.pop("cached_response", None) + 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) @@ -133,8 +134,8 @@ class HttpCacheMiddleware: def process_exception( self, request: Request, exception: Exception, spider: Spider - ) -> Union[Request, Response, None]: - cachedresponse: Optional[Response] = request.meta.pop("cached_response", None) + ) -> Request | Response | None: + cachedresponse: Response | None = request.meta.pop("cached_response", None) if cachedresponse is not None and isinstance( exception, self.DOWNLOAD_EXCEPTIONS ): @@ -147,7 +148,7 @@ class HttpCacheMiddleware: spider: Spider, response: Response, request: Request, - cachedresponse: Optional[Response], + cachedresponse: Response | None, ) -> None: if self.policy.should_cache_response(response, request): self.stats.inc_value("httpcache/store", spider=spider) diff --git a/scrapy/downloadermiddlewares/httpcompression.py b/scrapy/downloadermiddlewares/httpcompression.py index 56a58a750..84678b8e9 100644 --- a/scrapy/downloadermiddlewares/httpcompression.py +++ b/scrapy/downloadermiddlewares/httpcompression.py @@ -1,67 +1,133 @@ from __future__ import annotations -import io -import zlib -from typing import TYPE_CHECKING, List, Optional, Union +import warnings +from itertools import chain +from logging import getLogger +from typing import TYPE_CHECKING, Any -from scrapy import Request, Spider -from scrapy.crawler import Crawler -from scrapy.exceptions import NotConfigured +from scrapy import Request, Spider, signals +from scrapy.exceptions import IgnoreRequest, NotConfigured from scrapy.http import Response, TextResponse from scrapy.responsetypes import responsetypes -from scrapy.statscollectors import StatsCollector +from scrapy.utils._compression import ( + _DecompressionMaxSizeExceeded, + _inflate, + _unbrotli, + _unzstd, +) +from scrapy.utils.deprecate import ScrapyDeprecationWarning from scrapy.utils.gz import gunzip if TYPE_CHECKING: # typing.Self requires Python 3.11 from typing_extensions import Self -ACCEPTED_ENCODINGS: List[bytes] = [b"gzip", b"deflate"] + from scrapy.crawler import Crawler + from scrapy.statscollectors import StatsCollector + + +logger = getLogger(__name__) + +ACCEPTED_ENCODINGS: list[bytes] = [b"gzip", b"deflate"] try: - import brotli - + try: + import brotli # noqa: F401 + except ImportError: + import brotlicffi # noqa: F401 +except ImportError: + pass +else: ACCEPTED_ENCODINGS.append(b"br") -except ImportError: - pass try: - import zstandard - - ACCEPTED_ENCODINGS.append(b"zstd") + import zstandard # noqa: F401 except ImportError: pass +else: + ACCEPTED_ENCODINGS.append(b"zstd") class HttpCompressionMiddleware: """This middleware allows compressed (gzip, deflate) traffic to be - sent/received from web sites""" + sent/received from websites""" - def __init__(self, stats: Optional[StatsCollector] = None): - self.stats = stats + def __init__( + self, + stats: StatsCollector | None = None, + *, + crawler: Crawler | None = None, + ): + if not crawler: + self.stats = stats + self._max_size = 1073741824 + self._warn_size = 33554432 + return + self.stats = crawler.stats + self._max_size = crawler.settings.getint("DOWNLOAD_MAXSIZE") + self._warn_size = crawler.settings.getint("DOWNLOAD_WARNSIZE") + crawler.signals.connect(self.open_spider, signals.spider_opened) @classmethod def from_crawler(cls, crawler: Crawler) -> Self: if not crawler.settings.getbool("COMPRESSION_ENABLED"): raise NotConfigured - return cls(stats=crawler.stats) + try: + return cls(crawler=crawler) + except TypeError: + warnings.warn( + "HttpCompressionMiddleware subclasses must either modify " + "their '__init__' method to support a 'crawler' parameter or " + "reimplement their 'from_crawler' method.", + ScrapyDeprecationWarning, + ) + mw = cls() + mw.stats = crawler.stats + mw._max_size = crawler.settings.getint("DOWNLOAD_MAXSIZE") + mw._warn_size = crawler.settings.getint("DOWNLOAD_WARNSIZE") + crawler.signals.connect(mw.open_spider, signals.spider_opened) + return mw + + def open_spider(self, spider: Spider) -> None: + if hasattr(spider, "download_maxsize"): + self._max_size = spider.download_maxsize + if hasattr(spider, "download_warnsize"): + self._warn_size = spider.download_warnsize def process_request( self, request: Request, spider: Spider - ) -> Union[Request, Response, None]: + ) -> Request | Response | None: request.headers.setdefault("Accept-Encoding", b", ".join(ACCEPTED_ENCODINGS)) return None def process_response( self, request: Request, response: Response, spider: Spider - ) -> Union[Request, Response]: + ) -> Request | Response: if request.method == "HEAD": return response if isinstance(response, Response): content_encoding = response.headers.getlist("Content-Encoding") if content_encoding: - encoding = content_encoding.pop() - decoded_body = self._decode(response.body, encoding.lower()) + max_size = request.meta.get("download_maxsize", self._max_size) + warn_size = request.meta.get("download_warnsize", self._warn_size) + try: + decoded_body, content_encoding = self._handle_encoding( + response.body, content_encoding, max_size + ) + except _DecompressionMaxSizeExceeded: + 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." + ) + if len(response.body) < warn_size <= len(decoded_body): + logger.warning( + f"{response} body size after decompression " + f"({len(decoded_body)} B) is larger than the " + f"download warning size ({warn_size} B)." + ) + response.headers["Content-Encoding"] = content_encoding if self.stats: self.stats.inc_value( "httpcompression/response_bytes", @@ -74,36 +140,50 @@ class HttpCompressionMiddleware: respcls = responsetypes.from_args( headers=response.headers, url=response.url, body=decoded_body ) - kwargs = dict(cls=respcls, body=decoded_body) + kwargs: dict[str, Any] = {"body": decoded_body} if issubclass(respcls, TextResponse): # force recalculating the encoding until we make sure the # responsetypes guessing is reliable kwargs["encoding"] = None - response = response.replace(**kwargs) + response = response.replace(cls=respcls, **kwargs) if not content_encoding: del response.headers["Content-Encoding"] return response - def _decode(self, body: bytes, encoding: bytes) -> bytes: - if encoding == b"gzip" or encoding == b"x-gzip": - body = gunzip(body) + def _handle_encoding( + self, body: bytes, content_encoding: list[bytes], max_size: int + ) -> tuple[bytes, list[bytes]]: + to_decode, to_keep = self._split_encodings(content_encoding) + for encoding in to_decode: + body = self._decode(body, encoding, max_size) + return body, to_keep + def _split_encodings( + self, content_encoding: list[bytes] + ) -> tuple[list[bytes], list[bytes]]: + to_keep: list[bytes] = [ + encoding.strip().lower() + for encoding in chain.from_iterable( + encodings.split(b",") for encodings in content_encoding + ) + ] + to_decode: list[bytes] = [] + while to_keep: + encoding = to_keep.pop() + if encoding not in ACCEPTED_ENCODINGS: + to_keep.append(encoding) + return to_decode, to_keep + to_decode.append(encoding) + return to_decode, to_keep + + def _decode(self, body: bytes, encoding: bytes, max_size: int) -> bytes: + if encoding in {b"gzip", b"x-gzip"}: + return gunzip(body, max_size=max_size) if encoding == b"deflate": - try: - body = zlib.decompress(body) - except zlib.error: - # 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 - body = zlib.decompress(body, -15) + return _inflate(body, max_size=max_size) if encoding == b"br" and b"br" in ACCEPTED_ENCODINGS: - body = brotli.decompress(body) + return _unbrotli(body, max_size=max_size) if encoding == b"zstd" and b"zstd" in ACCEPTED_ENCODINGS: - # Using its streaming API since its simple API could handle only cases - # where there is content size data embedded in the frame - reader = zstandard.ZstdDecompressor().stream_reader(io.BytesIO(body)) - body = reader.read() + return _unzstd(body, max_size=max_size) return body diff --git a/scrapy/downloadermiddlewares/httpproxy.py b/scrapy/downloadermiddlewares/httpproxy.py index 335896ac1..2f3f2db47 100644 --- a/scrapy/downloadermiddlewares/httpproxy.py +++ b/scrapy/downloadermiddlewares/httpproxy.py @@ -1,7 +1,7 @@ from __future__ import annotations import base64 -from typing import TYPE_CHECKING, Dict, Optional, Tuple, Union +from typing import TYPE_CHECKING from urllib.parse import unquote, urlunparse from urllib.request import ( # type: ignore[attr-defined] _parse_proxy, @@ -9,10 +9,7 @@ from urllib.request import ( # type: ignore[attr-defined] proxy_bypass, ) -from scrapy import Request, Spider -from scrapy.crawler import Crawler from scrapy.exceptions import NotConfigured -from scrapy.http import Response from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.python import to_bytes @@ -20,11 +17,15 @@ if TYPE_CHECKING: # typing.Self requires Python 3.11 from typing_extensions import Self + from scrapy import Request, Spider + from scrapy.crawler import Crawler + from scrapy.http import Response + class HttpProxyMiddleware: - def __init__(self, auth_encoding: Optional[str] = "latin-1"): - self.auth_encoding: Optional[str] = auth_encoding - self.proxies: Dict[str, Tuple[Optional[bytes], str]] = {} + def __init__(self, auth_encoding: str | None = "latin-1"): + self.auth_encoding: str | None = auth_encoding + self.proxies: dict[str, tuple[bytes | None, str]] = {} for type_, url in getproxies().items(): try: self.proxies[type_] = self._get_proxy(url, type_) @@ -37,7 +38,7 @@ class HttpProxyMiddleware: def from_crawler(cls, crawler: Crawler) -> Self: if not crawler.settings.getbool("HTTPPROXY_ENABLED"): raise NotConfigured - auth_encoding: Optional[str] = crawler.settings.get("HTTPPROXY_AUTH_ENCODING") + auth_encoding: str | None = crawler.settings.get("HTTPPROXY_AUTH_ENCODING") return cls(auth_encoding) def _basic_auth_header(self, username: str, password: str) -> bytes: @@ -46,7 +47,7 @@ class HttpProxyMiddleware: ) return base64.b64encode(user_pass) - def _get_proxy(self, url: str, orig_type: str) -> Tuple[Optional[bytes], str]: + def _get_proxy(self, url: str, orig_type: str) -> tuple[bytes | None, str]: proxy_type, user, password, hostport = _parse_proxy(url) proxy_url = urlunparse((proxy_type or orig_type, hostport, "", "", "", "")) @@ -59,27 +60,34 @@ class HttpProxyMiddleware: def process_request( self, request: Request, spider: Spider - ) -> Union[Request, Response, None]: - creds, proxy_url = None, None + ) -> Request | Response | None: + creds, proxy_url, scheme = None, None, None if "proxy" in request.meta: if request.meta["proxy"] is not None: creds, proxy_url = self._get_proxy(request.meta["proxy"], "") elif self.proxies: parsed = urlparse_cached(request) - scheme = parsed.scheme + _scheme = parsed.scheme if ( # 'no_proxy' is only supported by http schemes - scheme not in ("http", "https") + _scheme not in ("http", "https") or (parsed.hostname and not proxy_bypass(parsed.hostname)) - ) and scheme in self.proxies: + ) and _scheme in self.proxies: + scheme = _scheme creds, proxy_url = self.proxies[scheme] - self._set_proxy_and_creds(request, proxy_url, creds) + self._set_proxy_and_creds(request, proxy_url, creds, scheme) return None def _set_proxy_and_creds( - self, request: Request, proxy_url: Optional[str], creds: Optional[bytes] + self, + request: Request, + proxy_url: str | None, + creds: bytes | None, + scheme: str | None, ) -> None: + if scheme: + request.meta["_scheme_proxy"] = True if proxy_url: request.meta["proxy"] = proxy_url elif request.meta.get("proxy") is not None: diff --git a/scrapy/downloadermiddlewares/offsite.py b/scrapy/downloadermiddlewares/offsite.py new file mode 100644 index 000000000..05ec4cad4 --- /dev/null +++ b/scrapy/downloadermiddlewares/offsite.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import logging +import re +import warnings +from typing import TYPE_CHECKING + +from scrapy import Request, Spider, signals +from scrapy.exceptions import IgnoreRequest +from scrapy.utils.httpobj import urlparse_cached + +if TYPE_CHECKING: + # typing.Self requires Python 3.11 + from typing_extensions import Self + + from scrapy.crawler import Crawler + from scrapy.statscollectors import StatsCollector + + +logger = logging.getLogger(__name__) + + +class OffsiteMiddleware: + @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) + 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) + + def process_request(self, request: Request, spider: Spider) -> None: + if request.dont_filter or self.should_follow(request, spider): + return None + 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": spider}, + ) + self.stats.inc_value("offsite/domains", spider=spider) + self.stats.inc_value("offsite/filtered", spider=spider) + raise IgnoreRequest + + 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) + elif port_pattern.search(domain): + message = ( + "allowed_domains accepts only domains without ports. " + f"Ignoring entry {domain} in allowed_domains." + ) + warnings.warn(message) + else: + domains.append(re.escape(domain)) + regex = rf'^(.*\.)?({"|".join(domains)})$' + return re.compile(regex) diff --git a/scrapy/downloadermiddlewares/redirect.py b/scrapy/downloadermiddlewares/redirect.py index 814b1a561..0b883b43a 100644 --- a/scrapy/downloadermiddlewares/redirect.py +++ b/scrapy/downloadermiddlewares/redirect.py @@ -1,16 +1,13 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, Any, List, Union, cast -from urllib.parse import urljoin, urlparse +from typing import TYPE_CHECKING, Any, cast +from urllib.parse import urljoin from w3lib.url import safe_url_string -from scrapy import Request, Spider -from scrapy.crawler import Crawler from scrapy.exceptions import IgnoreRequest, NotConfigured from scrapy.http import HtmlResponse, Response -from scrapy.settings import BaseSettings from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.response import get_meta_refresh @@ -18,6 +15,11 @@ if TYPE_CHECKING: # typing.Self requires Python 3.11 from typing_extensions import Self + from scrapy import Request, Spider + from scrapy.crawler import Crawler + from scrapy.settings import BaseSettings + + logger = logging.getLogger(__name__) @@ -27,13 +29,52 @@ def _build_redirect_request( redirect_request = source_request.replace( url=url, **kwargs, + cls=None, cookies=None, ) - if "Cookie" in redirect_request.headers: - source_request_netloc = urlparse_cached(source_request).netloc - redirect_request_netloc = urlparse_cached(redirect_request).netloc - if source_request_netloc != redirect_request_netloc: + if "_scheme_proxy" in redirect_request.meta: + source_request_scheme = urlparse_cached(source_request).scheme + redirect_request_scheme = urlparse_cached(redirect_request).scheme + if source_request_scheme != redirect_request_scheme: + redirect_request.meta.pop("_scheme_proxy") + redirect_request.meta.pop("proxy", None) + redirect_request.meta.pop("_auth_proxy", None) + redirect_request.headers.pop(b"Proxy-Authorization", None) + has_cookie_header = "Cookie" in redirect_request.headers + has_authorization_header = "Authorization" in redirect_request.headers + if has_cookie_header or has_authorization_header: + default_ports = {"http": 80, "https": 443} + + parsed_source_request = urlparse_cached(source_request) + source_scheme, source_host, source_port = ( + parsed_source_request.scheme, + parsed_source_request.hostname, + parsed_source_request.port + or default_ports.get(parsed_source_request.scheme), + ) + + parsed_redirect_request = urlparse_cached(redirect_request) + redirect_scheme, redirect_host, redirect_port = ( + parsed_redirect_request.scheme, + parsed_redirect_request.hostname, + parsed_redirect_request.port + or default_ports.get(parsed_redirect_request.scheme), + ) + + if has_cookie_header and ( + redirect_scheme not in {source_scheme, "https"} + or source_host != redirect_host + ): del redirect_request.headers["Cookie"] + + # https://fetch.spec.whatwg.org/#ref-for-cors-non-wildcard-request-header-name + if has_authorization_header and ( + source_scheme != redirect_scheme + or source_host != redirect_host + or source_port != redirect_port + ): + del redirect_request.headers["Authorization"] + return redirect_request @@ -103,7 +144,7 @@ class RedirectMiddleware(BaseRedirectMiddleware): def process_response( self, request: Request, response: Response, spider: Spider - ) -> Union[Request, Response]: + ) -> Request | Response: if ( request.meta.get("dont_redirect", False) or response.status in getattr(spider, "handle_httpstatus_list", []) @@ -119,13 +160,15 @@ class RedirectMiddleware(BaseRedirectMiddleware): assert response.headers["Location"] is not None location = safe_url_string(response.headers["Location"]) if response.headers["Location"].startswith(b"//"): - request_scheme = urlparse(request.url).scheme + request_scheme = urlparse_cached(request).scheme location = request_scheme + "://" + location.lstrip("/") redirected_url = urljoin(request.url, location) + redirected = _build_redirect_request(request, url=redirected_url) + if urlparse_cached(redirected).scheme not in {"http", "https"}: + return response if response.status in (301, 307, 308) or request.method == "HEAD": - redirected = _build_redirect_request(request, url=redirected_url) return self._redirect(redirected, request, spider, response.status) redirected = self._redirect_request_using_get(request, redirected_url) @@ -137,22 +180,26 @@ class MetaRefreshMiddleware(BaseRedirectMiddleware): def __init__(self, settings: BaseSettings): super().__init__(settings) - self._ignore_tags: List[str] = settings.getlist("METAREFRESH_IGNORE_TAGS") + self._ignore_tags: list[str] = settings.getlist("METAREFRESH_IGNORE_TAGS") self._maxdelay: int = settings.getint("METAREFRESH_MAXDELAY") def process_response( self, request: Request, response: Response, spider: Spider - ) -> Union[Request, Response]: + ) -> Request | Response: if ( request.meta.get("dont_redirect", False) or request.method == "HEAD" or not isinstance(response, HtmlResponse) + or urlparse_cached(request).scheme not in {"http", "https"} ): return response interval, url = get_meta_refresh(response, ignore_tags=self._ignore_tags) - if url and cast(float, interval) < self._maxdelay: - redirected = self._redirect_request_using_get(request, url) + if not url: + return response + 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") - return response diff --git a/scrapy/downloadermiddlewares/retry.py b/scrapy/downloadermiddlewares/retry.py index 3c494de78..7c0e2280c 100644 --- a/scrapy/downloadermiddlewares/retry.py +++ b/scrapy/downloadermiddlewares/retry.py @@ -7,20 +7,17 @@ RETRY_TIMES - how many times to retry a failed page RETRY_HTTP_CODES - which HTTP response codes to retry Failed pages are collected on the scraping process and rescheduled at the end, -once the spider has finished crawling all regular (non failed) pages. +once the spider has finished crawling all regular (non-failed) pages. """ + from __future__ import annotations import warnings from logging import Logger, getLogger -from typing import TYPE_CHECKING, Any, Optional, Tuple, Type, Union +from typing import TYPE_CHECKING, Any -from scrapy.crawler import Crawler from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning -from scrapy.http import Response -from scrapy.http.request import Request from scrapy.settings import BaseSettings, Settings -from scrapy.spiders import Spider from scrapy.utils.misc import load_object from scrapy.utils.python import global_object_name from scrapy.utils.response import response_status_message @@ -29,10 +26,16 @@ if TYPE_CHECKING: # typing.Self requires Python 3.11 from typing_extensions import Self + from scrapy.crawler import Crawler + from scrapy.http import Response + from scrapy.http.request import Request + from scrapy.spiders import Spider + + retry_logger = getLogger(__name__) -def backwards_compatibility_getattr(self: Any, name: str) -> Tuple[Any, ...]: +def backwards_compatibility_getattr(self: Any, name: str) -> tuple[Any, ...]: if name == "EXCEPTIONS_TO_RETRY": warnings.warn( "Attribute RetryMiddleware.EXCEPTIONS_TO_RETRY is deprecated. " @@ -57,12 +60,12 @@ def get_retry_request( request: Request, *, spider: Spider, - reason: Union[str, Exception, Type[Exception]] = "unspecified", - max_retry_times: Optional[int] = None, - priority_adjust: Optional[int] = None, + reason: str | Exception | type[Exception] = "unspecified", + max_retry_times: int | None = None, + priority_adjust: int | None = None, logger: Logger = retry_logger, stats_base_key: str = "retry", -) -> Optional[Request]: +) -> Request | None: """ Returns a new :class:`~scrapy.Request` object to retry the specified request, or ``None`` if retries of the specified request have been @@ -146,9 +149,7 @@ class RetryMiddleware(metaclass=BackwardsCompatibilityMetaclass): if not settings.getbool("RETRY_ENABLED"): raise NotConfigured self.max_retry_times = settings.getint("RETRY_TIMES") - self.retry_http_codes = set( - int(x) for x in settings.getlist("RETRY_HTTP_CODES") - ) + self.retry_http_codes = {int(x) for x in settings.getlist("RETRY_HTTP_CODES")} self.priority_adjust = settings.getint("RETRY_PRIORITY_ADJUST") try: @@ -166,7 +167,7 @@ class RetryMiddleware(metaclass=BackwardsCompatibilityMetaclass): def process_response( self, request: Request, response: Response, spider: Spider - ) -> Union[Request, Response]: + ) -> Request | Response: if request.meta.get("dont_retry", False): return response if response.status in self.retry_http_codes: @@ -176,7 +177,7 @@ class RetryMiddleware(metaclass=BackwardsCompatibilityMetaclass): def process_exception( self, request: Request, exception: Exception, spider: Spider - ) -> Union[Request, Response, None]: + ) -> Request | Response | None: if isinstance(exception, self.exceptions_to_retry) and not request.meta.get( "dont_retry", False ): @@ -186,9 +187,9 @@ class RetryMiddleware(metaclass=BackwardsCompatibilityMetaclass): def _retry( self, request: Request, - reason: Union[str, Exception, Type[Exception]], + reason: str | Exception | type[Exception], spider: Spider, - ) -> Optional[Request]: + ) -> 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) return get_retry_request( diff --git a/scrapy/downloadermiddlewares/robotstxt.py b/scrapy/downloadermiddlewares/robotstxt.py index 6a0ecb7bf..ea9f47d69 100644 --- a/scrapy/downloadermiddlewares/robotstxt.py +++ b/scrapy/downloadermiddlewares/robotstxt.py @@ -7,28 +7,32 @@ enable this middleware and enable the ROBOTSTXT_OBEY setting. from __future__ import annotations import logging -from typing import TYPE_CHECKING, Any, Dict, Optional, Union +from typing import TYPE_CHECKING, TypeVar from twisted.internet.defer import Deferred, maybeDeferred -from twisted.python.failure import Failure -from scrapy import Spider -from scrapy.crawler import Crawler from scrapy.exceptions import IgnoreRequest, NotConfigured from scrapy.http import Request, Response from scrapy.http.request import NO_CALLBACK -from scrapy.robotstxt import RobotParser 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 + from scrapy import Spider + from scrapy.crawler import Crawler + from scrapy.robotstxt import RobotParser + logger = logging.getLogger(__name__) +_T = TypeVar("_T") + class RobotsTxtMiddleware: DOWNLOAD_PRIORITY: int = 1000 @@ -37,11 +41,11 @@ class RobotsTxtMiddleware: if not crawler.settings.getbool("ROBOTSTXT_OBEY"): raise NotConfigured self._default_useragent: str = crawler.settings.get("USER_AGENT", "Scrapy") - self._robotstxt_useragent: Optional[str] = crawler.settings.get( + self._robotstxt_useragent: str | None = crawler.settings.get( "ROBOTSTXT_USER_AGENT", None ) self.crawler: Crawler = crawler - self._parsers: Dict[str, Union[RobotParser, Deferred, None]] = {} + self._parsers: dict[str, RobotParser | Deferred[RobotParser | None] | None] = {} self._parserimpl: RobotParser = load_object( crawler.settings.get("ROBOTSTXT_PARSER") ) @@ -53,22 +57,26 @@ class RobotsTxtMiddleware: def from_crawler(cls, crawler: Crawler) -> Self: return cls(crawler) - def process_request(self, request: Request, spider: Spider) -> Optional[Deferred]: + 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 = maybeDeferred(self.robot_parser, request, spider) - d.addCallback(self.process_request_2, request, spider) - return d + 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: Optional[RobotParser], request: Request, spider: Spider + self, rp: RobotParser | None, request: Request, spider: Spider ) -> None: if rp is None: return - useragent: Union[str, bytes, None] = self._robotstxt_useragent + useragent: str | bytes | None = self._robotstxt_useragent if not useragent: useragent = request.headers.get(b"User-Agent", self._default_useragent) assert useragent is not None @@ -84,7 +92,7 @@ class RobotsTxtMiddleware: def robot_parser( self, request: Request, spider: Spider - ) -> Union[RobotParser, Deferred, None]: + ) -> RobotParser | Deferred[RobotParser | None] | None: url = urlparse_cached(request) netloc = url.netloc @@ -107,9 +115,9 @@ class RobotsTxtMiddleware: parser = self._parsers[netloc] if isinstance(parser, Deferred): - d: Deferred = Deferred() + d: Deferred[RobotParser | None] = Deferred() - def cb(result: Any) -> Any: + def cb(result: RobotParser | None) -> RobotParser | None: d.callback(result) return result diff --git a/scrapy/downloadermiddlewares/stats.py b/scrapy/downloadermiddlewares/stats.py index df30e8ca4..fb0f30620 100644 --- a/scrapy/downloadermiddlewares/stats.py +++ b/scrapy/downloadermiddlewares/stats.py @@ -1,14 +1,10 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Dict, Union +from typing import TYPE_CHECKING from twisted.web import http -from scrapy import Request, Spider -from scrapy.crawler import Crawler from scrapy.exceptions import NotConfigured -from scrapy.http import Response -from scrapy.statscollectors import StatsCollector from scrapy.utils.python import global_object_name, to_bytes from scrapy.utils.request import request_httprepr @@ -16,8 +12,15 @@ if TYPE_CHECKING: # typing.Self requires Python 3.11 from typing_extensions import Self + from scrapy import Request, Spider + from scrapy.crawler import Crawler + from scrapy.http import Response + from scrapy.statscollectors import StatsCollector -def get_header_size(headers: Dict[str, Union[list, tuple]]) -> int: + +def get_header_size( + headers: dict[str, list[str | bytes] | tuple[str | bytes, ...]] +) -> int: size = 0 for key, value in headers.items(): if isinstance(value, (list, tuple)): @@ -44,7 +47,7 @@ class DownloaderStats: def process_request( self, request: Request, spider: Spider - ) -> Union[Request, Response, 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 @@ -55,7 +58,7 @@ class DownloaderStats: def process_response( self, request: Request, response: Response, spider: Spider - ) -> Union[Request, Response]: + ) -> Request | Response: self.stats.inc_value("downloader/response_count", spider=spider) self.stats.inc_value( f"downloader/response_status_count/{response.status}", spider=spider @@ -72,7 +75,7 @@ class DownloaderStats: def process_exception( self, request: Request, exception: Exception, spider: Spider - ) -> Union[Request, Response, None]: + ) -> Request | Response | None: ex_class = global_object_name(exception.__class__) self.stats.inc_value("downloader/exception_count", spider=spider) self.stats.inc_value( diff --git a/scrapy/downloadermiddlewares/useragent.py b/scrapy/downloadermiddlewares/useragent.py index 92f1ec897..ba379f862 100644 --- a/scrapy/downloadermiddlewares/useragent.py +++ b/scrapy/downloadermiddlewares/useragent.py @@ -2,16 +2,17 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Union +from typing import TYPE_CHECKING from scrapy import Request, Spider, signals -from scrapy.crawler import Crawler -from scrapy.http import Response if TYPE_CHECKING: # typing.Self requires Python 3.11 from typing_extensions import Self + from scrapy.crawler import Crawler + from scrapy.http import Response + class UserAgentMiddleware: """This middleware allows spiders to override the user_agent""" @@ -30,7 +31,7 @@ class UserAgentMiddleware: def process_request( self, request: Request, spider: Spider - ) -> Union[Request, Response, None]: + ) -> Request | Response | None: if self.user_agent: request.headers.setdefault(b"User-Agent", self.user_agent) return None diff --git a/scrapy/dupefilters.py b/scrapy/dupefilters.py index dd2420e98..d37d2741a 100644 --- a/scrapy/dupefilters.py +++ b/scrapy/dupefilters.py @@ -2,13 +2,8 @@ from __future__ import annotations import logging from pathlib import Path -from typing import TYPE_CHECKING, Optional, Set +from typing import TYPE_CHECKING -from twisted.internet.defer import Deferred - -from scrapy.http.request import Request -from scrapy.settings import BaseSettings -from scrapy.spiders import Spider from scrapy.utils.job import job_dir from scrapy.utils.request import ( RequestFingerprinter, @@ -17,10 +12,15 @@ from scrapy.utils.request import ( ) if TYPE_CHECKING: + from twisted.internet.defer import Deferred + # typing.Self requires Python 3.11 from typing_extensions import Self from scrapy.crawler import Crawler + from scrapy.http.request import Request + from scrapy.settings import BaseSettings + from scrapy.spiders import Spider class BaseDupeFilter: @@ -31,10 +31,10 @@ class BaseDupeFilter: def request_seen(self, request: Request) -> bool: return False - def open(self) -> Optional[Deferred]: + def open(self) -> Deferred[None] | None: pass - def close(self, reason: str) -> Optional[Deferred]: + def close(self, reason: str) -> Deferred[None] | None: pass def log(self, request: Request, spider: Spider) -> None: @@ -47,16 +47,16 @@ class RFPDupeFilter(BaseDupeFilter): def __init__( self, - path: Optional[str] = None, + path: str | None = None, debug: bool = False, *, - fingerprinter: Optional[RequestFingerprinterProtocol] = None, + fingerprinter: RequestFingerprinterProtocol | None = None, ) -> None: self.file = None self.fingerprinter: RequestFingerprinterProtocol = ( fingerprinter or RequestFingerprinter() ) - self.fingerprints: Set[str] = set() + self.fingerprints: set[str] = set() self.logdupes = True self.debug = debug self.logger = logging.getLogger(__name__) @@ -70,7 +70,7 @@ class RFPDupeFilter(BaseDupeFilter): cls, settings: BaseSettings, *, - fingerprinter: Optional[RequestFingerprinterProtocol] = None, + fingerprinter: RequestFingerprinterProtocol | None = None, ) -> Self: debug = settings.getbool("DUPEFILTER_DEBUG") return cls(job_dir(settings), debug, fingerprinter=fingerprinter) diff --git a/scrapy/exceptions.py b/scrapy/exceptions.py index 6d188c489..e7ecdbe0c 100644 --- a/scrapy/exceptions.py +++ b/scrapy/exceptions.py @@ -4,6 +4,7 @@ Scrapy core exceptions These exceptions are documented in docs/topics/exceptions.rst. Please don't add new exceptions here without documenting them there. """ + from typing import Any # Internal diff --git a/scrapy/exporters.py b/scrapy/exporters.py index f85f1dad8..c9350a956 100644 --- a/scrapy/exporters.py +++ b/scrapy/exporters.py @@ -2,17 +2,22 @@ Item Exporters are used to export/serialize items into different formats. """ +from __future__ import annotations + import csv -import io import marshal -import pickle +import pickle # nosec import pprint -from collections.abc import Mapping -from xml.sax.saxutils import XMLGenerator +from collections.abc import Callable, Iterable, Mapping +from io import BytesIO, TextIOWrapper +from json import JSONEncoder +from typing import Any +from xml.sax.saxutils import XMLGenerator # nosec +from xml.sax.xmlreader import AttributesImpl # nosec from itemadapter import ItemAdapter, is_item -from scrapy.item import Item +from scrapy.item import Field, Item from scrapy.utils.python import is_listlike, to_bytes, to_unicode from scrapy.utils.serialize import ScrapyJSONEncoder @@ -29,36 +34,42 @@ __all__ = [ class BaseItemExporter: - def __init__(self, *, dont_fail=False, **kwargs): - self._kwargs = kwargs + def __init__(self, *, dont_fail: bool = False, **kwargs: Any): + self._kwargs: dict[str, Any] = kwargs self._configure(kwargs, dont_fail=dont_fail) - def _configure(self, options, dont_fail=False): + def _configure(self, options: dict[str, Any], dont_fail: bool = False) -> None: """Configure the exporter by popping options from the ``options`` dict. If dont_fail is set, it won't raise an exception on unexpected options (useful for using with keyword arguments in subclasses ``__init__`` methods) """ - self.encoding = options.pop("encoding", None) - self.fields_to_export = options.pop("fields_to_export", None) - self.export_empty_fields = options.pop("export_empty_fields", False) - self.indent = options.pop("indent", None) + self.encoding: str | None = options.pop("encoding", None) + self.fields_to_export: Mapping[str, str] | Iterable[str] | None = options.pop( + "fields_to_export", None + ) + self.export_empty_fields: bool = options.pop("export_empty_fields", False) + self.indent: int | None = options.pop("indent", None) if not dont_fail and options: raise TypeError(f"Unexpected options: {', '.join(options.keys())}") - def export_item(self, item): + def export_item(self, item: Any) -> None: raise NotImplementedError - def serialize_field(self, field, name, value): - serializer = field.get("serializer", lambda x: x) + def serialize_field( + self, field: Mapping[str, Any] | Field, name: str, value: Any + ) -> Any: + serializer: Callable[[Any], Any] = field.get("serializer", lambda x: x) return serializer(value) - def start_exporting(self): + def start_exporting(self) -> None: pass - def finish_exporting(self): + def finish_exporting(self) -> None: pass - def _get_serialized_fields(self, item, default_value=None, include_empty=None): + def _get_serialized_fields( + self, item: Any, default_value: Any = None, include_empty: bool | None = None + ) -> Iterable[tuple[str, Any]]: """Return the fields to export as an iterable of tuples (name, serialized_value) """ @@ -100,22 +111,22 @@ class BaseItemExporter: class JsonLinesItemExporter(BaseItemExporter): - def __init__(self, file, **kwargs): + def __init__(self, file: BytesIO, **kwargs: Any): super().__init__(dont_fail=True, **kwargs) - self.file = file + self.file: BytesIO = file self._kwargs.setdefault("ensure_ascii", not self.encoding) - self.encoder = ScrapyJSONEncoder(**self._kwargs) + self.encoder: JSONEncoder = ScrapyJSONEncoder(**self._kwargs) - def export_item(self, item): + def export_item(self, item: Any) -> None: itemdict = dict(self._get_serialized_fields(item)) data = self.encoder.encode(itemdict) + "\n" self.file.write(to_bytes(data, self.encoding)) class JsonItemExporter(BaseItemExporter): - def __init__(self, file, **kwargs): + def __init__(self, file: BytesIO, **kwargs: Any): super().__init__(dont_fail=True, **kwargs) - self.file = file + self.file: BytesIO = file # there is a small difference between the behaviour or JsonItemExporter.indent # and ScrapyJSONEncoder.indent. ScrapyJSONEncoder.indent=None is needed to prevent # the addition of newlines everywhere @@ -127,26 +138,26 @@ class JsonItemExporter(BaseItemExporter): self.encoder = ScrapyJSONEncoder(**self._kwargs) self.first_item = True - def _beautify_newline(self): + def _beautify_newline(self) -> None: if self.indent is not None: self.file.write(b"\n") - def _add_comma_after_first(self): + def _add_comma_after_first(self) -> None: if self.first_item: self.first_item = False else: self.file.write(b",") self._beautify_newline() - def start_exporting(self): + def start_exporting(self) -> None: self.file.write(b"[") self._beautify_newline() - def finish_exporting(self): + def finish_exporting(self) -> None: self._beautify_newline() self.file.write(b"]") - def export_item(self, item): + def export_item(self, item: Any) -> None: itemdict = dict(self._get_serialized_fields(item)) data = to_bytes(self.encoder.encode(itemdict), self.encoding) self._add_comma_after_first() @@ -154,7 +165,7 @@ class JsonItemExporter(BaseItemExporter): class XmlItemExporter(BaseItemExporter): - def __init__(self, file, **kwargs): + def __init__(self, file: BytesIO, **kwargs: Any): self.item_element = kwargs.pop("item_element", "item") self.root_element = kwargs.pop("root_element", "items") super().__init__(**kwargs) @@ -162,22 +173,22 @@ class XmlItemExporter(BaseItemExporter): self.encoding = "utf-8" self.xg = XMLGenerator(file, encoding=self.encoding) - def _beautify_newline(self, new_item=False): + def _beautify_newline(self, new_item: bool = False) -> None: if self.indent is not None and (self.indent > 0 or new_item): self.xg.characters("\n") - def _beautify_indent(self, depth=1): + def _beautify_indent(self, depth: int = 1) -> None: if self.indent: self.xg.characters(" " * self.indent * depth) - def start_exporting(self): + def start_exporting(self) -> None: self.xg.startDocument() - self.xg.startElement(self.root_element, {}) + self.xg.startElement(self.root_element, AttributesImpl({})) self._beautify_newline(new_item=True) - def export_item(self, item): + def export_item(self, item: Any) -> None: self._beautify_indent(depth=1) - self.xg.startElement(self.item_element, {}) + self.xg.startElement(self.item_element, AttributesImpl({})) self._beautify_newline() for name, value in self._get_serialized_fields(item, default_value=""): self._export_xml_field(name, value, depth=2) @@ -185,13 +196,13 @@ class XmlItemExporter(BaseItemExporter): self.xg.endElement(self.item_element) self._beautify_newline(new_item=True) - def finish_exporting(self): + def finish_exporting(self) -> None: self.xg.endElement(self.root_element) self.xg.endDocument() - def _export_xml_field(self, name, serialized_value, depth): + def _export_xml_field(self, name: str, serialized_value: Any, depth: int) -> None: self._beautify_indent(depth=depth) - self.xg.startElement(name, {}) + self.xg.startElement(name, AttributesImpl({})) if hasattr(serialized_value, "items"): self._beautify_newline() for subname, value in serialized_value.items(): @@ -213,17 +224,17 @@ class XmlItemExporter(BaseItemExporter): class CsvItemExporter(BaseItemExporter): def __init__( self, - file, - include_headers_line=True, - join_multivalued=",", - errors=None, - **kwargs, + file: BytesIO, + include_headers_line: bool = True, + join_multivalued: str = ",", + errors: str | None = None, + **kwargs: Any, ): super().__init__(dont_fail=True, **kwargs) if not self.encoding: self.encoding = "utf-8" self.include_headers_line = include_headers_line - self.stream = io.TextIOWrapper( + self.stream = TextIOWrapper( file, line_buffering=False, write_through=True, @@ -235,11 +246,13 @@ class CsvItemExporter(BaseItemExporter): self._headers_not_written = True self._join_multivalued = join_multivalued - def serialize_field(self, field, name, value): - serializer = field.get("serializer", self._join_if_needed) + def serialize_field( + self, field: Mapping[str, Any] | Field, name: str, value: Any + ) -> Any: + serializer: Callable[[Any], Any] = field.get("serializer", self._join_if_needed) return serializer(value) - def _join_if_needed(self, value): + def _join_if_needed(self, value: Any) -> Any: if isinstance(value, (list, tuple)): try: return self._join_multivalued.join(value) @@ -247,7 +260,7 @@ class CsvItemExporter(BaseItemExporter): pass return value - def export_item(self, item): + def export_item(self, item: Any) -> None: if self._headers_not_written: self._headers_not_written = False self._write_headers_and_set_fields_to_export(item) @@ -256,36 +269,38 @@ class CsvItemExporter(BaseItemExporter): values = list(self._build_row(x for _, x in fields)) self.csv_writer.writerow(values) - def finish_exporting(self): + def finish_exporting(self) -> None: self.stream.detach() # Avoid closing the wrapped file. - def _build_row(self, values): + def _build_row(self, values: Iterable[Any]) -> Iterable[Any]: for s in values: try: yield to_unicode(s, self.encoding) except TypeError: yield s - def _write_headers_and_set_fields_to_export(self, item): + def _write_headers_and_set_fields_to_export(self, item: Any) -> None: if self.include_headers_line: if not self.fields_to_export: # use declared field names, or keys if the item is a dict self.fields_to_export = ItemAdapter(item).field_names() + fields: Iterable[str] if isinstance(self.fields_to_export, Mapping): fields = self.fields_to_export.values() else: + assert self.fields_to_export fields = self.fields_to_export row = list(self._build_row(fields)) self.csv_writer.writerow(row) class PickleItemExporter(BaseItemExporter): - def __init__(self, file, protocol=4, **kwargs): + def __init__(self, file: BytesIO, protocol: int = 4, **kwargs: Any): super().__init__(**kwargs) - self.file = file - self.protocol = protocol + self.file: BytesIO = file + self.protocol: int = protocol - def export_item(self, item): + def export_item(self, item: Any) -> None: d = dict(self._get_serialized_fields(item)) pickle.dump(d, self.file, self.protocol) @@ -299,20 +314,20 @@ class MarshalItemExporter(BaseItemExporter): opened in binary mode, a :class:`~io.BytesIO` object, etc) """ - def __init__(self, file, **kwargs): + def __init__(self, file: BytesIO, **kwargs: Any): super().__init__(**kwargs) - self.file = file + self.file: BytesIO = file - def export_item(self, item): + def export_item(self, item: Any) -> None: marshal.dump(dict(self._get_serialized_fields(item)), self.file) class PprintItemExporter(BaseItemExporter): - def __init__(self, file, **kwargs): + def __init__(self, file: BytesIO, **kwargs: Any): super().__init__(**kwargs) - self.file = file + self.file: BytesIO = file - def export_item(self, item): + def export_item(self, item: Any) -> None: itemdict = dict(self._get_serialized_fields(item)) self.file.write(to_bytes(pprint.pformat(itemdict) + "\n")) @@ -327,16 +342,20 @@ class PythonItemExporter(BaseItemExporter): .. _msgpack: https://pypi.org/project/msgpack/ """ - def _configure(self, options, dont_fail=False): + def _configure(self, options: dict[str, Any], dont_fail: bool = False) -> None: super()._configure(options, dont_fail) if not self.encoding: self.encoding = "utf-8" - def serialize_field(self, field, name, value): - serializer = field.get("serializer", self._serialize_value) + def serialize_field( + self, field: Mapping[str, Any] | Field, name: str, value: Any + ) -> Any: + serializer: Callable[[Any], Any] = field.get( + "serializer", self._serialize_value + ) return serializer(value) - def _serialize_value(self, value): + def _serialize_value(self, value: Any) -> Any: if isinstance(value, Item): return self.export_item(value) if is_item(value): @@ -347,10 +366,10 @@ class PythonItemExporter(BaseItemExporter): return to_unicode(value, encoding=self.encoding) return value - def _serialize_item(self, item): + def _serialize_item(self, item: Any) -> Iterable[tuple[str | bytes, Any]]: for key, value in ItemAdapter(item).items(): yield key, self._serialize_value(value) - def export_item(self, item): - result = dict(self._get_serialized_fields(item)) + def export_item(self, item: Any) -> dict[str | bytes, Any]: # type: ignore[override] + result: dict[str | bytes, Any] = dict(self._get_serialized_fields(item)) return result diff --git a/scrapy/extension.py b/scrapy/extension.py index 4e365cfa1..9f978fa32 100644 --- a/scrapy/extension.py +++ b/scrapy/extension.py @@ -3,13 +3,21 @@ The Extension Manager See documentation in docs/topics/extensions.rst """ + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + from scrapy.middleware import MiddlewareManager from scrapy.utils.conf import build_component_list +if TYPE_CHECKING: + from scrapy.settings import Settings + class ExtensionManager(MiddlewareManager): component_name = "extension" @classmethod - def _get_mwlist_from_settings(cls, settings): + def _get_mwlist_from_settings(cls, settings: Settings) -> list[Any]: return build_component_list(settings.getwithbase("EXTENSIONS")) diff --git a/scrapy/extensions/closespider.py b/scrapy/extensions/closespider.py index 4307b4170..dff8bc97e 100644 --- a/scrapy/extensions/closespider.py +++ b/scrapy/extensions/closespider.py @@ -4,68 +4,96 @@ conditions are met. See documentation in docs/topics/extensions.rst """ +from __future__ import annotations + import logging from collections import defaultdict +from typing import TYPE_CHECKING, Any -from scrapy import signals +from scrapy import Request, Spider, signals from scrapy.exceptions import NotConfigured +if TYPE_CHECKING: + from twisted.python.failure import Failure + + # typing.Self requires Python 3.11 + from typing_extensions import Self + + from scrapy.crawler import Crawler + from scrapy.http import Response + + logger = logging.getLogger(__name__) class CloseSpider: - def __init__(self, crawler): - self.crawler = crawler + def __init__(self, crawler: Crawler): + self.crawler: Crawler = crawler - self.close_on = { + self.close_on: dict[str, Any] = { "timeout": crawler.settings.getfloat("CLOSESPIDER_TIMEOUT"), "itemcount": crawler.settings.getint("CLOSESPIDER_ITEMCOUNT"), "pagecount": crawler.settings.getint("CLOSESPIDER_PAGECOUNT"), "errorcount": crawler.settings.getint("CLOSESPIDER_ERRORCOUNT"), "timeout_no_item": crawler.settings.getint("CLOSESPIDER_TIMEOUT_NO_ITEM"), + "pagecount_no_item": crawler.settings.getint( + "CLOSESPIDER_PAGECOUNT_NO_ITEM" + ), } if not any(self.close_on.values()): raise NotConfigured - self.counter = defaultdict(int) + self.counter: defaultdict[str, int] = defaultdict(int) if self.close_on.get("errorcount"): crawler.signals.connect(self.error_count, signal=signals.spider_error) - if self.close_on.get("pagecount"): + if self.close_on.get("pagecount") or self.close_on.get("pagecount_no_item"): crawler.signals.connect(self.page_count, signal=signals.response_received) if self.close_on.get("timeout"): crawler.signals.connect(self.spider_opened, signal=signals.spider_opened) - if self.close_on.get("itemcount"): + if self.close_on.get("itemcount") or self.close_on.get("pagecount_no_item"): crawler.signals.connect(self.item_scraped, signal=signals.item_scraped) if self.close_on.get("timeout_no_item"): - self.timeout_no_item = self.close_on["timeout_no_item"] - self.items_in_period = 0 + self.timeout_no_item: int = self.close_on["timeout_no_item"] + self.items_in_period: int = 0 crawler.signals.connect( self.spider_opened_no_item, signal=signals.spider_opened ) crawler.signals.connect( self.item_scraped_no_item, signal=signals.item_scraped ) + crawler.signals.connect(self.spider_closed, signal=signals.spider_closed) @classmethod - def from_crawler(cls, crawler): + def from_crawler(cls, crawler: Crawler) -> Self: return cls(crawler) - def error_count(self, failure, response, spider): + 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") - def page_count(self, response, request, spider): + 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") + 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") - def spider_opened(self, spider): + def spider_opened(self, spider: Spider) -> None: from twisted.internet import reactor + assert self.crawler.engine self.task = reactor.callLater( self.close_on["timeout"], self.crawler.engine.close_spider, @@ -73,21 +101,23 @@ class CloseSpider: reason="closespider_timeout", ) - def item_scraped(self, item, spider): + 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") - def spider_closed(self, spider): - task = getattr(self, "task", False) + def spider_closed(self, spider: Spider) -> None: + task = getattr(self, "task", None) if task and task.active(): task.cancel() - task_no_item = getattr(self, "task_no_item", False) + task_no_item = getattr(self, "task_no_item", None) if task_no_item and task_no_item.running: task_no_item.stop() - def spider_opened_no_item(self, spider): + def spider_opened_no_item(self, spider: Spider) -> None: from twisted.internet import task self.task_no_item = task.LoopingCall(self._count_items_produced, spider) @@ -98,10 +128,10 @@ class CloseSpider: f"{self.timeout_no_item} seconds." ) - def item_scraped_no_item(self, item, spider): + def item_scraped_no_item(self, item: Any, spider: Spider) -> None: self.items_in_period += 1 - def _count_items_produced(self, spider): + def _count_items_produced(self, spider: Spider) -> None: if self.items_in_period >= 1: self.items_in_period = 0 else: @@ -109,4 +139,5 @@ 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") diff --git a/scrapy/extensions/corestats.py b/scrapy/extensions/corestats.py index 302a615f2..779cd5d1c 100644 --- a/scrapy/extensions/corestats.py +++ b/scrapy/extensions/corestats.py @@ -1,18 +1,30 @@ """ Extension for collecting core stats like items scraped and start/finish times """ -from datetime import datetime, timezone -from scrapy import signals +from __future__ import annotations + +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Any + +from scrapy import Spider, signals + +if TYPE_CHECKING: + # typing.Self requires Python 3.11 + from typing_extensions import Self + + from scrapy.crawler import Crawler + from scrapy.statscollectors import StatsCollector class CoreStats: - def __init__(self, stats): - self.stats = stats - self.start_time = None + def __init__(self, stats: StatsCollector): + self.stats: StatsCollector = stats + self.start_time: datetime | None = None @classmethod - def from_crawler(cls, crawler): + 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.spider_closed, signal=signals.spider_closed) @@ -21,11 +33,12 @@ class CoreStats: crawler.signals.connect(o.response_received, signal=signals.response_received) return o - def spider_opened(self, spider): + 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) - def spider_closed(self, spider, reason): + 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() @@ -35,13 +48,13 @@ class CoreStats: self.stats.set_value("finish_time", finish_time, spider=spider) self.stats.set_value("finish_reason", reason, spider=spider) - def item_scraped(self, item, spider): + def item_scraped(self, item: Any, spider: Spider) -> None: self.stats.inc_value("item_scraped_count", spider=spider) - def response_received(self, spider): + def response_received(self, spider: Spider) -> None: self.stats.inc_value("response_received_count", spider=spider) - def item_dropped(self, item, spider, exception): + 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) diff --git a/scrapy/extensions/debug.py b/scrapy/extensions/debug.py index 1b6c7777f..d3c225bcd 100644 --- a/scrapy/extensions/debug.py +++ b/scrapy/extensions/debug.py @@ -4,22 +4,34 @@ Extensions for debugging Scrapy See documentation in docs/topics/extensions.rst """ +from __future__ import annotations + import logging import signal import sys import threading import traceback from pdb import Pdb +from typing import TYPE_CHECKING from scrapy.utils.engine import format_engine_status from scrapy.utils.trackref import format_live_refs +if TYPE_CHECKING: + from types import FrameType + + # typing.Self requires Python 3.11 + from typing_extensions import Self + + from scrapy.crawler import Crawler + + logger = logging.getLogger(__name__) class StackTraceDump: - def __init__(self, crawler=None): - self.crawler = crawler + def __init__(self, crawler: Crawler): + self.crawler: Crawler = crawler try: signal.signal(signal.SIGUSR2, self.dump_stacktrace) signal.signal(signal.SIGQUIT, self.dump_stacktrace) @@ -28,10 +40,11 @@ class StackTraceDump: pass @classmethod - def from_crawler(cls, crawler): + def from_crawler(cls, crawler: Crawler) -> Self: return cls(crawler) - def dump_stacktrace(self, signum, frame): + def dump_stacktrace(self, signum: int, frame: FrameType | None) -> None: + assert self.crawler.engine log_args = { "stackdumps": self._thread_stacks(), "enginestatus": format_engine_status(self.crawler.engine), @@ -44,8 +57,8 @@ class StackTraceDump: extra={"crawler": self.crawler}, ) - def _thread_stacks(self): - id2name = dict((th.ident, th.name) for th in threading.enumerate()) + def _thread_stacks(self) -> str: + id2name = {th.ident: th.name for th in threading.enumerate()} dumps = "" for id_, frame in sys._current_frames().items(): name = id2name.get(id_, "") @@ -55,12 +68,13 @@ class StackTraceDump: class Debugger: - def __init__(self): + def __init__(self) -> None: try: signal.signal(signal.SIGUSR2, self._enter_debugger) except AttributeError: # win32 platforms don't support SIGUSR signals pass - def _enter_debugger(self, signum, frame): - Pdb().set_trace(frame.f_back) + def _enter_debugger(self, signum: int, frame: FrameType | None) -> None: + assert frame + Pdb().set_trace(frame.f_back) # noqa: T100 diff --git a/scrapy/extensions/feedexport.py b/scrapy/extensions/feedexport.py index e5e363b52..eb1698ce5 100644 --- a/scrapy/extensions/feedexport.py +++ b/scrapy/extensions/feedexport.py @@ -4,24 +4,28 @@ Feed Exports extension See documentation in docs/topics/feed-exports.rst """ +from __future__ import annotations + import logging import re import sys import warnings +from collections.abc import Callable from datetime import datetime, timezone from pathlib import Path, PureWindowsPath from tempfile import NamedTemporaryFile -from typing import IO, Any, Callable, Dict, List, Optional, Tuple, Union +from typing import IO, TYPE_CHECKING, Any, Optional, Protocol, TypeVar, cast from urllib.parse import unquote, urlparse -from twisted.internet import defer, threads -from twisted.internet.defer import DeferredList +from twisted.internet.defer import Deferred, DeferredList, maybeDeferred +from twisted.internet.threads import deferToThread from w3lib.url import file_uri_to_path from zope.interface import Interface, implementer from scrapy import Spider, signals from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning from scrapy.extensions.postprocessing import PostProcessingManager +from scrapy.settings import Settings from scrapy.utils.boto import is_botocore_available from scrapy.utils.conf import feed_complete_default_values_from_settings from scrapy.utils.defer import maybe_deferred_to_future @@ -31,7 +35,18 @@ from scrapy.utils.log import failure_to_exc_info from scrapy.utils.misc import build_from_crawler, load_object from scrapy.utils.python import without_none_values -logger = logging.getLogger(__name__) +if TYPE_CHECKING: + from collections.abc import Iterable + + from _typeshed import OpenBinaryMode + from twisted.python.failure import Failure + + # typing.Self requires Python 3.11 + from typing_extensions import Self + + from scrapy.crawler import Crawler + from scrapy.exporters import BaseItemExporter + from scrapy.settings import BaseSettings try: import boto3 # noqa: F401 @@ -41,7 +56,21 @@ except ImportError: IS_BOTO3_AVAILABLE = False -def build_storage(builder, uri, *args, feed_options=None, preargs=(), **kwargs): +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: kwargs["feed_options"] = feed_options return builder(*preargs, uri, *args, **kwargs) @@ -55,10 +84,10 @@ class ItemFilter: :type feed_options: dict """ - feed_options: Optional[dict] - item_classes: Tuple + feed_options: dict[str, Any] | None + item_classes: tuple[type, ...] - def __init__(self, feed_options: Optional[dict]) -> None: + def __init__(self, feed_options: dict[str, Any] | None) -> None: self.feed_options = feed_options if feed_options is not None: self.item_classes = tuple( @@ -66,7 +95,7 @@ class ItemFilter: for item_class in feed_options.get("item_classes") or () ) else: - self.item_classes = tuple() + self.item_classes = () def accepts(self, item: Any) -> bool: """ @@ -97,28 +126,49 @@ class IFeedStorage(Interface): """Store the given file stream""" +class FeedStorageProtocol(Protocol): + """Reimplementation of ``IFeedStorage`` that can be used in type hints.""" + + def __init__(self, uri: str, *, feed_options: dict[str, Any] | None = None): + """Initialize the storage with the parameters given in the URI and the + feed-specific options (see :setting:`FEEDS`)""" + + def open(self, spider: Spider) -> IO[bytes]: + """Open the storage for the given spider. It must return a file-like + object that will be used for the exporters""" + + def store(self, file: IO[bytes]) -> Deferred[None] | None: + """Store the given file stream""" + + @implementer(IFeedStorage) class BlockingFeedStorage: - def open(self, spider): + def open(self, spider: Spider) -> IO[bytes]: path = spider.crawler.settings["FEED_TEMPDIR"] if path and not Path(path).is_dir(): raise OSError("Not a Directory: " + str(path)) return NamedTemporaryFile(prefix="feed-", dir=path) - def store(self, file): - return threads.deferToThread(self._store_in_thread, file) + def store(self, file: IO[bytes]) -> Deferred[None] | None: + return deferToThread(self._store_in_thread, file) - def _store_in_thread(self, file): + def _store_in_thread(self, file: IO[bytes]) -> None: raise NotImplementedError @implementer(IFeedStorage) class StdoutFeedStorage: - def __init__(self, uri, _stdout=None, *, feed_options=None): + def __init__( + self, + uri: str, + _stdout: IO[bytes] | None = None, + *, + feed_options: dict[str, Any] | None = None, + ): if not _stdout: _stdout = sys.stdout.buffer - self._stdout = _stdout + self._stdout: IO[bytes] = _stdout if feed_options and feed_options.get("overwrite", False) is True: logger.warning( "Standard output (stdout) storage does not support " @@ -127,61 +177,68 @@ class StdoutFeedStorage: "it to False." ) - def open(self, spider): + def open(self, spider: Spider) -> IO[bytes]: return self._stdout - def store(self, file): + def store(self, file: IO[bytes]) -> Deferred[None] | None: pass @implementer(IFeedStorage) class FileFeedStorage: - def __init__(self, uri, *, feed_options=None): - self.path = file_uri_to_path(uri) + def __init__(self, uri: str, *, feed_options: dict[str, Any] | None = None): + self.path: str = file_uri_to_path(uri) feed_options = feed_options or {} - self.write_mode = "wb" if feed_options.get("overwrite", False) else "ab" + self.write_mode: OpenBinaryMode = ( + "wb" if feed_options.get("overwrite", False) else "ab" + ) - def open(self, spider) -> IO[Any]: + def open(self, spider: Spider) -> IO[bytes]: dirname = Path(self.path).parent if dirname and not dirname.exists(): dirname.mkdir(parents=True) return Path(self.path).open(self.write_mode) - def store(self, file): + def store(self, file: IO[bytes]) -> Deferred[None] | None: file.close() + return None class S3FeedStorage(BlockingFeedStorage): def __init__( self, - uri, - access_key=None, - secret_key=None, - acl=None, - endpoint_url=None, + uri: str, + access_key: str | None = None, + secret_key: str | None = None, + acl: str | None = None, + endpoint_url: str | None = None, *, - feed_options=None, - session_token=None, - region_name=None, + feed_options: dict[str, Any] | None = None, + session_token: str | None = None, + region_name: str | None = None, ): if not is_botocore_available(): raise NotConfigured("missing botocore library") u = urlparse(uri) - self.bucketname = u.hostname - self.access_key = u.username or access_key - self.secret_key = u.password or secret_key - self.session_token = session_token - self.keyname = u.path[1:] # remove first "/" - self.acl = acl - self.endpoint_url = endpoint_url - self.region_name = region_name + assert u.hostname + self.bucketname: str = u.hostname + self.access_key: str | None = u.username or access_key + self.secret_key: str | None = u.password or secret_key + self.session_token: str | None = session_token + self.keyname: str = u.path[1:] # remove first "/" + self.acl: str | None = acl + self.endpoint_url: str | None = endpoint_url + self.region_name: str | None = region_name + # It can be either botocore.client.BaseClient or mypy_boto3_s3.S3Client, + # there seems to be no good way to infer it statically. + self.s3_client: Any if IS_BOTO3_AVAILABLE: import boto3.session - session = boto3.session.Session() + boto3_session = boto3.session.Session() - self.s3_client = session.client( + self.s3_client = boto3_session.client( "s3", aws_access_key_id=self.access_key, aws_secret_access_key=self.secret_key, @@ -198,9 +255,9 @@ class S3FeedStorage(BlockingFeedStorage): import botocore.session - session = botocore.session.get_session() + botocore_session = botocore.session.get_session() - self.s3_client = session.create_client( + self.s3_client = botocore_session.create_client( "s3", aws_access_key_id=self.access_key, aws_secret_access_key=self.secret_key, @@ -217,7 +274,13 @@ class S3FeedStorage(BlockingFeedStorage): ) @classmethod - def from_crawler(cls, crawler, uri, *, feed_options=None): + def from_crawler( + cls, + crawler: Crawler, + uri: str, + *, + feed_options: dict[str, Any] | None = None, + ) -> Self: return build_storage( cls, uri, @@ -230,8 +293,9 @@ class S3FeedStorage(BlockingFeedStorage): feed_options=feed_options, ) - def _store_in_thread(self, file): + def _store_in_thread(self, file: IO[bytes]) -> None: file.seek(0) + kwargs: dict[str, Any] if IS_BOTO3_AVAILABLE: kwargs = {"ExtraArgs": {"ACL": self.acl}} if self.acl else {} self.s3_client.upload_fileobj( @@ -246,22 +310,23 @@ class S3FeedStorage(BlockingFeedStorage): class GCSFeedStorage(BlockingFeedStorage): - def __init__(self, uri, project_id, acl): - self.project_id = project_id - self.acl = acl + def __init__(self, uri: str, project_id: str | None, acl: str | None): + self.project_id: str | None = project_id + self.acl: str | None = acl u = urlparse(uri) - self.bucket_name = u.hostname - self.blob_name = u.path[1:] # remove first "/" + assert u.hostname + self.bucket_name: str = u.hostname + self.blob_name: str = u.path[1:] # remove first "/" @classmethod - def from_crawler(cls, crawler, uri): + def from_crawler(cls, crawler: Crawler, uri: str) -> Self: return cls( uri, crawler.settings["GCS_PROJECT_ID"], crawler.settings["FEED_STORAGE_GCS_ACL"] or None, ) - def _store_in_thread(self, file): + def _store_in_thread(self, file: IO[bytes]) -> None: file.seek(0) from google.cloud.storage import Client @@ -277,7 +342,7 @@ class FTPFeedStorage(BlockingFeedStorage): uri: str, use_active_mode: bool = False, *, - feed_options: Optional[Dict[str, Any]] = None, + feed_options: dict[str, Any] | None = None, ): u = urlparse(uri) if not u.hostname: @@ -291,7 +356,13 @@ class FTPFeedStorage(BlockingFeedStorage): self.overwrite: bool = not feed_options or feed_options.get("overwrite", True) @classmethod - def from_crawler(cls, crawler, uri, *, feed_options=None): + def from_crawler( + cls, + crawler: Crawler, + uri: str, + *, + feed_options: dict[str, Any] | None = None, + ) -> Self: return build_storage( cls, uri, @@ -299,7 +370,7 @@ class FTPFeedStorage(BlockingFeedStorage): feed_options=feed_options, ) - def _store_in_thread(self, file): + def _store_in_thread(self, file: IO[bytes]) -> None: ftp_store_file( path=self.path, file=file, @@ -315,46 +386,51 @@ class FTPFeedStorage(BlockingFeedStorage): class FeedSlot: def __init__( self, - storage, - uri, - format, - store_empty, - batch_id, - uri_template, - filter, - feed_options, - spider, - exporters, - settings, - crawler, + storage: FeedStorageProtocol, + uri: str, + format: str, + store_empty: bool, + batch_id: int, + uri_template: str, + filter: ItemFilter, + feed_options: dict[str, Any], + spider: Spider, + exporters: dict[str, type[BaseItemExporter]], + settings: BaseSettings, + crawler: Crawler, ): - self.file = None - self.exporter = None - self.storage = storage + self.file: IO[bytes] | None = None + self.exporter: BaseItemExporter | None = None + self.storage: FeedStorageProtocol = storage # feed params - self.batch_id = batch_id - self.format = format - self.store_empty = store_empty - self.uri_template = uri_template - self.uri = uri - self.filter = filter + self.batch_id: int = batch_id + self.format: str = format + self.store_empty: bool = store_empty + self.uri_template: str = uri_template + self.uri: str = uri + self.filter: ItemFilter = filter # exporter params - self.feed_options = feed_options - self.spider = spider - self.exporters = exporters - self.settings = settings - self.crawler = crawler + self.feed_options: dict[str, Any] = feed_options + self.spider: Spider = spider + self.exporters: dict[str, type[BaseItemExporter]] = exporters + self.settings: BaseSettings = settings + self.crawler: Crawler = crawler # flags - self.itemcount = 0 - self._exporting = False - self._fileloaded = False + self.itemcount: int = 0 + self._exporting: bool = False + self._fileloaded: bool = False - def start_exporting(self): + def start_exporting(self) -> None: if not self._fileloaded: self.file = self.storage.open(self.spider) if "postprocessing" in self.feed_options: - self.file = PostProcessingManager( - self.feed_options["postprocessing"], self.file, self.feed_options + self.file = cast( + IO[bytes], + PostProcessingManager( + self.feed_options["postprocessing"], + self.file, + self.feed_options, + ), ) self.exporter = self._get_exporter( file=self.file, @@ -367,17 +443,23 @@ class FeedSlot: self._fileloaded = True if not self._exporting: + assert self.exporter self.exporter.start_exporting() self._exporting = True - def _get_instance(self, objcls, *args, **kwargs): + def _get_instance( + self, objcls: type[BaseItemExporter], *args: Any, **kwargs: Any + ) -> BaseItemExporter: return build_from_crawler(objcls, self.crawler, *args, **kwargs) - def _get_exporter(self, file, format, *args, **kwargs): + def _get_exporter( + self, file: IO[bytes], format: str, *args: Any, **kwargs: Any + ) -> BaseItemExporter: return self._get_instance(self.exporters[format], file, *args, **kwargs) - def finish_exporting(self): + def finish_exporting(self) -> None: if self._exporting: + assert self.exporter self.exporter.finish_exporting() self._exporting = False @@ -389,22 +471,22 @@ _FeedSlot = create_deprecated_class( class FeedExporter: - _pending_deferreds: List[defer.Deferred] = [] + _pending_deferreds: list[Deferred[None]] = [] @classmethod - def from_crawler(cls, crawler): + def from_crawler(cls, crawler: Crawler) -> Self: exporter = cls(crawler) crawler.signals.connect(exporter.open_spider, signals.spider_opened) crawler.signals.connect(exporter.close_spider, signals.spider_closed) crawler.signals.connect(exporter.item_scraped, signals.item_scraped) return exporter - def __init__(self, crawler): - self.crawler = crawler - self.settings = crawler.settings + def __init__(self, crawler: Crawler): + self.crawler: Crawler = crawler + self.settings: Settings = crawler.settings self.feeds = {} - self.slots = [] - self.filters = {} + self.slots: list[FeedSlot] = [] + self.filters: dict[str, ItemFilter] = {} if not self.settings["FEEDS"] and not self.settings["FEED_URI"]: raise NotConfigured @@ -436,8 +518,12 @@ class FeedExporter: ) self.filters[uri] = self._load_filter(feed_options) - self.storages = self._load_components("FEED_STORAGES") - self.exporters = self._load_components("FEED_EXPORTERS") + self.storages: dict[str, type[FeedStorageProtocol]] = self._load_components( + "FEED_STORAGES" + ) + self.exporters: dict[str, type[BaseItemExporter]] = self._load_components( + "FEED_EXPORTERS" + ) for uri, feed_options in self.feeds.items(): if not self._storage_supported(uri, feed_options): raise NotConfigured @@ -446,7 +532,7 @@ class FeedExporter: if not self._exporter_supported(feed_options["format"]): raise NotConfigured - def open_spider(self, spider): + def open_spider(self, spider: Spider) -> None: for uri, feed_options in self.feeds.items(): uri_params = self._get_uri_params(spider, feed_options["uri_params"]) self.slots.append( @@ -459,7 +545,7 @@ class FeedExporter: ) ) - async def close_spider(self, spider): + async def close_spider(self, spider: Spider) -> None: for slot in self.slots: self._close_slot(slot, spider) @@ -472,8 +558,9 @@ class FeedExporter: self.crawler.signals.send_catch_log_deferred(signals.feed_exporter_closed) ) - def _close_slot(self, slot, spider): - def get_file(slot_): + def _close_slot(self, slot: FeedSlot, spider: Spider) -> Deferred[None] | None: + def get_file(slot_: FeedSlot) -> IO[bytes]: + assert slot_.file if isinstance(slot_.file, PostProcessingManager): slot_.file.close() return slot_.file.file @@ -491,7 +578,7 @@ class FeedExporter: return None logmsg = f"{slot.format} feed ({slot.itemcount} items) in: {slot.uri}" - d = defer.maybeDeferred(slot.storage.store, get_file(slot)) + d: Deferred[None] = maybeDeferred(slot.storage.store, get_file(slot)) # type: ignore[call-overload] d.addCallback( self._handle_store_success, logmsg, spider, type(slot.storage).__name__ @@ -509,20 +596,33 @@ class FeedExporter: return d - def _handle_store_error(self, f, logmsg, spider, slot_type): + def _handle_store_error( + self, f: Failure, logmsg: str, spider: Spider, slot_type: str + ) -> None: logger.error( "Error storing %s", logmsg, exc_info=failure_to_exc_info(f), extra={"spider": spider}, ) + assert self.crawler.stats self.crawler.stats.inc_value(f"feedexport/failed_count/{slot_type}") - def _handle_store_success(self, f, logmsg, spider, slot_type): + def _handle_store_success( + self, result: Any, logmsg: str, spider: Spider, slot_type: str + ) -> None: logger.info("Stored %s", logmsg, extra={"spider": spider}) + assert self.crawler.stats self.crawler.stats.inc_value(f"feedexport/success_count/{slot_type}") - def _start_new_batch(self, batch_id, uri, feed_options, spider, uri_template): + def _start_new_batch( + self, + batch_id: int, + uri: str, + feed_options: dict[str, Any], + spider: Spider, + uri_template: str, + ) -> FeedSlot: """ Redirect the output data stream to a new file. Execute multiple times if FEED_EXPORT_BATCH_ITEM_COUNT setting or FEEDS.batch_item_count is specified @@ -545,11 +645,11 @@ class FeedExporter: spider=spider, exporters=self.exporters, settings=self.settings, - crawler=getattr(self, "crawler", None), + crawler=self.crawler, ) return slot - def item_scraped(self, item, spider): + def item_scraped(self, item: Any, spider: Spider) -> None: slots = [] for slot in self.slots: if not slot.filter.accepts(item): @@ -559,6 +659,7 @@ class FeedExporter: continue slot.start_exporting() + assert slot.exporter slot.exporter.export_item(item) slot.itemcount += 1 # create new slot for each slot with itemcount == FEED_EXPORT_BATCH_ITEM_COUNT and close the old one @@ -583,8 +684,10 @@ class FeedExporter: slots.append(slot) self.slots = slots - def _load_components(self, setting_prefix): - conf = without_none_values(self.settings.getwithbase(setting_prefix)) + def _load_components(self, setting_prefix: str) -> dict[str, Any]: + conf = without_none_values( + cast(dict[str, str], self.settings.getwithbase(setting_prefix)) + ) d = {} for k, v in conf.items(): try: @@ -593,12 +696,13 @@ class FeedExporter: pass return d - def _exporter_supported(self, format): + def _exporter_supported(self, format: str) -> bool: if format in self.exporters: return True logger.error("Unknown feed format: %(format)s", {"format": format}) + return False - def _settings_are_valid(self): + def _settings_are_valid(self) -> bool: """ If FEED_EXPORT_BATCH_ITEM_COUNT setting or FEEDS.batch_item_count is specified uri has to contain %(batch_time)s or %(batch_id)d to distinguish different files of partial output @@ -616,7 +720,7 @@ class FeedExporter: return False return True - def _storage_supported(self, uri, feed_options): + def _storage_supported(self, uri: str, feed_options: dict[str, Any]) -> bool: scheme = urlparse(uri).scheme if scheme in self.storages or PureWindowsPath(uri).drive: try: @@ -629,8 +733,11 @@ class FeedExporter: ) else: logger.error("Unknown feed storage scheme: %(scheme)s", {"scheme": scheme}) + return False - def _get_storage(self, uri, feed_options): + def _get_storage( + self, uri: str, feed_options: dict[str, Any] + ) -> FeedStorageProtocol: """Fork of create_instance specific to feed storage classes It supports not passing the *feed_options* parameters to classes that @@ -639,11 +746,14 @@ class FeedExporter: feedcls = self.storages.get(urlparse(uri).scheme, self.storages["file"]) crawler = getattr(self, "crawler", None) - def build_instance(builder, *preargs): + def build_instance( + builder: type[FeedStorageProtocol], *preargs: Any + ) -> FeedStorageProtocol: return build_storage( builder, uri, feed_options=feed_options, preargs=preargs ) + instance: FeedStorageProtocol if crawler and hasattr(feedcls, "from_crawler"): instance = build_instance(feedcls.from_crawler, crawler) method_name = "from_crawler" @@ -660,9 +770,9 @@ class FeedExporter: def _get_uri_params( self, spider: Spider, - uri_params_function: Optional[Union[str, Callable[[dict, Spider], dict]]], - slot: Optional[FeedSlot] = None, - ) -> dict: + uri_params_function: str | UriParamsCallableT | None, + slot: FeedSlot | None = None, + ) -> dict[str, Any]: params = {} for k in dir(spider): params[k] = getattr(spider, k) @@ -670,7 +780,7 @@ class FeedExporter: params["time"] = utc_now.replace(microsecond=0).isoformat().replace(":", "-") params["batch_time"] = utc_now.isoformat().replace(":", "-") params["batch_id"] = slot.batch_id + 1 if slot is not None else 1 - uripar_function = ( + uripar_function: UriParamsCallableT = ( load_object(uri_params_function) if uri_params_function else lambda params, _: params @@ -678,7 +788,9 @@ class FeedExporter: new_params = uripar_function(params, spider) return new_params if new_params is not None else params - def _load_filter(self, feed_options): + def _load_filter(self, feed_options: dict[str, Any]) -> ItemFilter: # load the item filter if declared else load the default filter class - item_filter_class = load_object(feed_options.get("item_filter", ItemFilter)) + item_filter_class: type[ItemFilter] = load_object( + feed_options.get("item_filter", ItemFilter) + ) return item_filter_class(feed_options) diff --git a/scrapy/extensions/httpcache.py b/scrapy/extensions/httpcache.py index 7e4f047a8..0e6120c21 100644 --- a/scrapy/extensions/httpcache.py +++ b/scrapy/extensions/httpcache.py @@ -1,60 +1,82 @@ +from __future__ import annotations + import gzip import logging -import pickle +import os +import pickle # nosec from email.utils import mktime_tz, parsedate_tz from importlib import import_module from pathlib import Path from time import time +from types import ModuleType +from typing import IO, TYPE_CHECKING, Any, cast from weakref import WeakKeyDictionary from w3lib.http import headers_dict_to_raw, headers_raw_to_dict from scrapy.http import Headers, Response -from scrapy.http.request import Request from scrapy.responsetypes import responsetypes -from scrapy.spiders import Spider from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.project import data_path from scrapy.utils.python import to_bytes, to_unicode +from scrapy.utils.request import RequestFingerprinter + +if TYPE_CHECKING: + from collections.abc import Callable + + # 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 + logger = logging.getLogger(__name__) class DummyPolicy: - def __init__(self, settings): - self.ignore_schemes = settings.getlist("HTTPCACHE_IGNORE_SCHEMES") - self.ignore_http_codes = [ + def __init__(self, settings: BaseSettings): + self.ignore_schemes: list[str] = settings.getlist("HTTPCACHE_IGNORE_SCHEMES") + self.ignore_http_codes: list[int] = [ int(x) for x in settings.getlist("HTTPCACHE_IGNORE_HTTP_CODES") ] - def should_cache_request(self, request): + def should_cache_request(self, request: Request) -> bool: return urlparse_cached(request).scheme not in self.ignore_schemes - def should_cache_response(self, response, request): + def should_cache_response(self, response: Response, request: Request) -> bool: return response.status not in self.ignore_http_codes - def is_cached_response_fresh(self, cachedresponse, request): + def is_cached_response_fresh( + self, cachedresponse: Response, request: Request + ) -> bool: return True - def is_cached_response_valid(self, cachedresponse, response, request): + def is_cached_response_valid( + self, cachedresponse: Response, response: Response, request: Request + ) -> bool: return True class RFC2616Policy: MAXAGE = 3600 * 24 * 365 # one year - def __init__(self, settings): - self.always_store = settings.getbool("HTTPCACHE_ALWAYS_STORE") - self.ignore_schemes = settings.getlist("HTTPCACHE_IGNORE_SCHEMES") - self._cc_parsed = WeakKeyDictionary() - self.ignore_response_cache_controls = [ + def __init__(self, settings: BaseSettings): + self.always_store: bool = settings.getbool("HTTPCACHE_ALWAYS_STORE") + self.ignore_schemes: list[str] = settings.getlist("HTTPCACHE_IGNORE_SCHEMES") + self._cc_parsed: WeakKeyDictionary[ + Request | Response, dict[bytes, bytes | None] + ] = WeakKeyDictionary() + self.ignore_response_cache_controls: list[bytes] = [ to_bytes(cc) for cc in settings.getlist("HTTPCACHE_IGNORE_RESPONSE_CACHE_CONTROLS") ] - def _parse_cachecontrol(self, r): + def _parse_cachecontrol(self, r: Request | Response) -> dict[bytes, bytes | None]: if r not in self._cc_parsed: cch = r.headers.get(b"Cache-Control", b"") + assert cch is not None parsed = parse_cachecontrol(cch) if isinstance(r, Response): for key in self.ignore_response_cache_controls: @@ -62,7 +84,7 @@ class RFC2616Policy: self._cc_parsed[r] = parsed return self._cc_parsed[r] - def should_cache_request(self, request): + def should_cache_request(self, request: Request) -> bool: if urlparse_cached(request).scheme in self.ignore_schemes: return False cc = self._parse_cachecontrol(request) @@ -72,7 +94,7 @@ class RFC2616Policy: # Any other is eligible for caching return True - def should_cache_response(self, response, request): + def should_cache_response(self, response: Response, request: Request) -> bool: # What is cacheable - https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.9.1 # Response cacheability - https://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec13.4 # Status code 206 is not included because cache can not deal with partial contents @@ -100,7 +122,9 @@ class RFC2616Policy: # info and can not be revalidated return False - def is_cached_response_fresh(self, cachedresponse, request): + def is_cached_response_fresh( + self, cachedresponse: Response, request: Request + ) -> bool: cc = self._parse_cachecontrol(cachedresponse) ccreq = self._parse_cachecontrol(request) if b"no-cache" in cc or b"no-cache" in ccreq: @@ -141,7 +165,9 @@ class RFC2616Policy: self._set_conditional_validators(request, cachedresponse) return False - def is_cached_response_valid(self, cachedresponse, response, request): + def is_cached_response_valid( + self, cachedresponse: Response, response: Response, request: Request + ) -> bool: # Use the cached response if the new response is a server error, # as long as the old response didn't specify must-revalidate. if response.status >= 500: @@ -152,7 +178,9 @@ class RFC2616Policy: # Use the cached response if the server says it hasn't changed. return response.status == 304 - def _set_conditional_validators(self, request, cachedresponse): + def _set_conditional_validators( + self, request: Request, cachedresponse: Response + ) -> None: if b"Last-Modified" in cachedresponse.headers: request.headers[b"If-Modified-Since"] = cachedresponse.headers[ b"Last-Modified" @@ -161,13 +189,15 @@ class RFC2616Policy: if b"ETag" in cachedresponse.headers: request.headers[b"If-None-Match"] = cachedresponse.headers[b"ETag"] - def _get_max_age(self, cc): + def _get_max_age(self, cc: dict[bytes, bytes | None]) -> int | None: try: - return max(0, int(cc[b"max-age"])) + return max(0, int(cc[b"max-age"])) # type: ignore[arg-type] except (KeyError, ValueError): return None - def _compute_freshness_lifetime(self, response, request, now): + def _compute_freshness_lifetime( + self, response: Response, request: Request, now: float + ) -> float: # Reference nsHttpResponseHead::ComputeFreshnessLifetime # https://dxr.mozilla.org/mozilla-central/source/netwerk/protocol/http/nsHttpResponseHead.cpp#706 cc = self._parse_cachecontrol(response) @@ -198,10 +228,12 @@ class RFC2616Policy: # Insufficient information to compute freshness lifetime return 0 - def _compute_current_age(self, response, request, now): + def _compute_current_age( + self, response: Response, request: Request, now: float + ) -> float: # Reference nsHttpResponseHead::ComputeCurrentAge # https://dxr.mozilla.org/mozilla-central/source/netwerk/protocol/http/nsHttpResponseHead.cpp#658 - currentage = 0 + currentage: float = 0 # If Date header is not set we assume it is a fast connection, and # clock is in sync with the server date = rfc1123_to_epoch(response.headers.get(b"Date")) or now @@ -210,7 +242,7 @@ class RFC2616Policy: if b"Age" in response.headers: try: - age = int(response.headers[b"Age"]) + age = int(response.headers[b"Age"]) # type: ignore[arg-type] currentage = max(currentage, age) except ValueError: pass @@ -219,13 +251,13 @@ class RFC2616Policy: class DbmCacheStorage: - def __init__(self, settings): - self.cachedir = data_path(settings["HTTPCACHE_DIR"], createdir=True) - self.expiration_secs = settings.getint("HTTPCACHE_EXPIRATION_SECS") - self.dbmodule = import_module(settings["HTTPCACHE_DBM_MODULE"]) - self.db = None + def __init__(self, settings: BaseSettings): + self.cachedir: str = data_path(settings["HTTPCACHE_DIR"], createdir=True) + self.expiration_secs: int = settings.getint("HTTPCACHE_EXPIRATION_SECS") + self.dbmodule: ModuleType = import_module(settings["HTTPCACHE_DBM_MODULE"]) + self.db: Any = None # the real type is private - def open_spider(self, spider: Spider): + def open_spider(self, spider: Spider) -> None: dbpath = Path(self.cachedir, f"{spider.name}.db") self.db = self.dbmodule.open(str(dbpath), "c") @@ -235,15 +267,16 @@ class DbmCacheStorage: extra={"spider": spider}, ) - self._fingerprinter = spider.crawler.request_fingerprinter + assert spider.crawler.request_fingerprinter + self._fingerprinter: RequestFingerprinter = spider.crawler.request_fingerprinter - def close_spider(self, spider): + def close_spider(self, spider: Spider) -> None: self.db.close() - def retrieve_response(self, spider, request): + def retrieve_response(self, spider: Spider, request: Request) -> Response | None: data = self._read_data(spider, request) if data is None: - return # not cached + return None # not cached url = data["url"] status = data["status"] headers = Headers(data["headers"]) @@ -252,7 +285,9 @@ class DbmCacheStorage: response = respcls(url=url, headers=headers, status=status, body=body) return response - def store_response(self, spider, request, response): + def store_response( + self, spider: Spider, request: Request, response: Response + ) -> None: key = self._fingerprinter.fingerprint(request).hex() data = { "status": response.status, @@ -263,28 +298,31 @@ class DbmCacheStorage: self.db[f"{key}_data"] = pickle.dumps(data, protocol=4) self.db[f"{key}_time"] = str(time()) - def _read_data(self, spider, request): + def _read_data(self, spider: Spider, request: Request) -> dict[str, Any] | None: key = self._fingerprinter.fingerprint(request).hex() db = self.db tkey = f"{key}_time" if tkey not in db: - return # not found + return None # not found ts = db[tkey] if 0 < self.expiration_secs < time() - float(ts): - return # expired + return None # expired - return pickle.loads(db[f"{key}_data"]) + return cast(dict[str, Any], pickle.loads(db[f"{key}_data"])) # nosec class FilesystemCacheStorage: - def __init__(self, settings): - self.cachedir = data_path(settings["HTTPCACHE_DIR"]) - self.expiration_secs = settings.getint("HTTPCACHE_EXPIRATION_SECS") - self.use_gzip = settings.getbool("HTTPCACHE_GZIP") - self._open = gzip.open if self.use_gzip else open + def __init__(self, settings: BaseSettings): + self.cachedir: str = data_path(settings["HTTPCACHE_DIR"]) + self.expiration_secs: int = settings.getint("HTTPCACHE_EXPIRATION_SECS") + self.use_gzip: bool = settings.getbool("HTTPCACHE_GZIP") + # https://github.com/python/mypy/issues/10740 + self._open: Callable[Concatenate[str | os.PathLike, str, ...], IO[bytes]] = ( + gzip.open if self.use_gzip else open # type: ignore[assignment] + ) - def open_spider(self, spider: Spider): + def open_spider(self, spider: Spider) -> None: logger.debug( "Using filesystem cache storage in %(cachedir)s", {"cachedir": self.cachedir}, @@ -294,27 +332,29 @@ class FilesystemCacheStorage: assert spider.crawler.request_fingerprinter self._fingerprinter = spider.crawler.request_fingerprinter - def close_spider(self, spider): + def close_spider(self, spider: Spider) -> None: pass - def retrieve_response(self, spider: Spider, request: Request): + def retrieve_response(self, spider: Spider, request: Request) -> Response | None: """Return response if present in cache, or None otherwise.""" metadata = self._read_meta(spider, request) if metadata is None: - return # not cached + return None # not cached rpath = Path(self._get_request_path(spider, request)) with self._open(rpath / "response_body", "rb") as f: body = f.read() with self._open(rpath / "response_headers", "rb") as f: rawheaders = f.read() - url = metadata.get("response_url") + url = metadata["response_url"] status = metadata["status"] headers = Headers(headers_raw_to_dict(rawheaders)) respcls = responsetypes.from_args(headers=headers, url=url, body=body) response = respcls(url=url, headers=headers, status=status, body=body) return response - def store_response(self, spider: Spider, request: Request, response): + def store_response( + self, spider: Spider, request: Request, response: Response + ) -> None: """Store the given response in the cache.""" rpath = Path(self._get_request_path(spider, request)) if not rpath.exists(): @@ -343,19 +383,19 @@ class FilesystemCacheStorage: key = self._fingerprinter.fingerprint(request).hex() return str(Path(self.cachedir, spider.name, key[0:2], key)) - def _read_meta(self, spider: Spider, request: Request): + def _read_meta(self, spider: Spider, request: Request) -> dict[str, Any] | None: rpath = Path(self._get_request_path(spider, request)) metapath = rpath / "pickled_meta" if not metapath.exists(): - return # not found + return None # not found mtime = metapath.stat().st_mtime if 0 < self.expiration_secs < time() - mtime: - return # expired + return None # expired with self._open(metapath, "rb") as f: - return pickle.load(f) + return cast(dict[str, Any], pickle.load(f)) # nosec -def parse_cachecontrol(header): +def parse_cachecontrol(header: bytes) -> dict[bytes, bytes | None]: """Parse Cache-Control header https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.9 @@ -375,9 +415,9 @@ def parse_cachecontrol(header): return directives -def rfc1123_to_epoch(date_str): +def rfc1123_to_epoch(date_str: str | bytes | None) -> int | None: try: - date_str = to_unicode(date_str, encoding="ascii") - return mktime_tz(parsedate_tz(date_str)) + date_str = to_unicode(date_str, encoding="ascii") # type: ignore[arg-type] + return mktime_tz(parsedate_tz(date_str)) # type: ignore[arg-type] except Exception: return None diff --git a/scrapy/extensions/logstats.py b/scrapy/extensions/logstats.py index 78874a6db..e829d8b92 100644 --- a/scrapy/extensions/logstats.py +++ b/scrapy/extensions/logstats.py @@ -1,58 +1,96 @@ +from __future__ import annotations + import logging +from typing import TYPE_CHECKING from twisted.internet import task -from scrapy import signals +from scrapy import Spider, signals from scrapy.exceptions import NotConfigured +if TYPE_CHECKING: + # typing.Self requires Python 3.11 + from typing_extensions import Self + + from scrapy.crawler import Crawler + from scrapy.statscollectors import StatsCollector + + logger = logging.getLogger(__name__) class LogStats: - """Log basic scraping stats periodically""" + """Log basic scraping stats periodically like: + * RPM - Requests per Minute + * IPM - Items per Minute + """ - def __init__(self, stats, interval=60.0): - self.stats = stats - self.interval = interval - self.multiplier = 60.0 / self.interval - self.task = None + def __init__(self, stats: StatsCollector, interval: float = 60.0): + self.stats: StatsCollector = stats + self.interval: float = interval + self.multiplier: float = 60.0 / self.interval + self.task: task.LoopingCall | None = None @classmethod - def from_crawler(cls, crawler): - interval = crawler.settings.getfloat("LOGSTATS_INTERVAL") + def from_crawler(cls, crawler: Crawler) -> Self: + interval: float = crawler.settings.getfloat("LOGSTATS_INTERVAL") if not interval: raise NotConfigured + assert crawler.stats o = cls(crawler.stats, interval) 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): - self.pagesprev = 0 - self.itemsprev = 0 + def spider_opened(self, spider: Spider) -> None: + self.pagesprev: int = 0 + self.itemsprev: int = 0 self.task = task.LoopingCall(self.log, spider) self.task.start(self.interval) - def log(self, spider): - items = self.stats.get_value("item_scraped_count", 0) - pages = self.stats.get_value("response_received_count", 0) - irate = (items - self.itemsprev) * self.multiplier - prate = (pages - self.pagesprev) * self.multiplier - self.pagesprev, self.itemsprev = pages, items + def log(self, spider: Spider) -> None: + self.calculate_stats() msg = ( "Crawled %(pages)d pages (at %(pagerate)d pages/min), " "scraped %(items)d items (at %(itemrate)d items/min)" ) log_args = { - "pages": pages, - "pagerate": prate, - "items": items, - "itemrate": irate, + "pages": self.pages, + "pagerate": self.prate, + "items": self.items, + "itemrate": self.irate, } logger.info(msg, log_args, extra={"spider": spider}) - def spider_closed(self, spider, reason): + def calculate_stats(self) -> None: + self.items: int = self.stats.get_value("item_scraped_count", 0) + self.pages: int = self.stats.get_value("response_received_count", 0) + self.irate: float = (self.items - self.itemsprev) * self.multiplier + self.prate: float = (self.pages - self.pagesprev) * self.multiplier + self.pagesprev, self.itemsprev = self.pages, self.items + + def spider_closed(self, spider: Spider, reason: str) -> None: if self.task and self.task.running: self.task.stop() + + rpm_final, ipm_final = self.calculate_final_stats(spider) + self.stats.set_value("responses_per_minute", rpm_final) + self.stats.set_value("items_per_minute", ipm_final) + + def calculate_final_stats( + self, spider: Spider + ) -> tuple[None, None] | tuple[float, float]: + start_time = self.stats.get_value("start_time") + finished_time = self.stats.get_value("finished_time") + + if not start_time or not finished_time: + return None, None + + mins_elapsed = (finished_time - start_time).seconds / 60 + + items = self.stats.get_value("item_scraped_count", 0) + pages = self.stats.get_value("response_received_count", 0) + + return (pages / mins_elapsed), (items / mins_elapsed) diff --git a/scrapy/extensions/memdebug.py b/scrapy/extensions/memdebug.py index 03ede0681..3cbbb64e5 100644 --- a/scrapy/extensions/memdebug.py +++ b/scrapy/extensions/memdebug.py @@ -4,26 +4,37 @@ MemoryDebugger extension See documentation in docs/topics/extensions.rst """ -import gc +from __future__ import annotations -from scrapy import signals +import gc +from typing import TYPE_CHECKING + +from scrapy import Spider, signals from scrapy.exceptions import NotConfigured from scrapy.utils.trackref import live_refs +if TYPE_CHECKING: + # typing.Self requires Python 3.11 + from typing_extensions import Self + + from scrapy.crawler import Crawler + from scrapy.statscollectors import StatsCollector + class MemoryDebugger: - def __init__(self, stats): - self.stats = stats + def __init__(self, stats: StatsCollector): + self.stats: StatsCollector = stats @classmethod - def from_crawler(cls, crawler): + def from_crawler(cls, crawler: Crawler) -> Self: if not crawler.settings.getbool("MEMDEBUG_ENABLED"): raise NotConfigured + assert crawler.stats o = cls(crawler.stats) crawler.signals.connect(o.spider_closed, signal=signals.spider_closed) return o - def spider_closed(self, spider, reason): + def spider_closed(self, spider: Spider, reason: str) -> None: gc.collect() self.stats.set_value( "memdebug/gc_garbage_count", len(gc.garbage), spider=spider diff --git a/scrapy/extensions/memusage.py b/scrapy/extensions/memusage.py index ca766c938..73d864d5d 100644 --- a/scrapy/extensions/memusage.py +++ b/scrapy/extensions/memusage.py @@ -3,11 +3,15 @@ MemoryUsage extension See documentation in docs/topics/extensions.rst """ + +from __future__ import annotations + import logging import socket import sys from importlib import import_module from pprint import pformat +from typing import TYPE_CHECKING from twisted.internet import task @@ -16,11 +20,18 @@ from scrapy.exceptions import NotConfigured from scrapy.mail import MailSender from scrapy.utils.engine import get_engine_status +if TYPE_CHECKING: + # typing.Self requires Python 3.11 + from typing_extensions import Self + + from scrapy.crawler import Crawler + + logger = logging.getLogger(__name__) class MemoryUsage: - def __init__(self, crawler): + def __init__(self, crawler: Crawler): if not crawler.settings.getbool("MEMUSAGE_ENABLED"): raise NotConfigured try: @@ -29,32 +40,33 @@ class MemoryUsage: except ImportError: raise NotConfigured - self.crawler = crawler - self.warned = False - self.notify_mails = crawler.settings.getlist("MEMUSAGE_NOTIFY_MAIL") - self.limit = crawler.settings.getint("MEMUSAGE_LIMIT_MB") * 1024 * 1024 - self.warning = crawler.settings.getint("MEMUSAGE_WARNING_MB") * 1024 * 1024 - self.check_interval = crawler.settings.getfloat( + self.crawler: Crawler = crawler + self.warned: bool = False + self.notify_mails: list[str] = crawler.settings.getlist("MEMUSAGE_NOTIFY_MAIL") + self.limit: int = crawler.settings.getint("MEMUSAGE_LIMIT_MB") * 1024 * 1024 + self.warning: int = crawler.settings.getint("MEMUSAGE_WARNING_MB") * 1024 * 1024 + self.check_interval: float = crawler.settings.getfloat( "MEMUSAGE_CHECK_INTERVAL_SECONDS" ) - self.mail = MailSender.from_settings(crawler.settings) + self.mail: MailSender = MailSender.from_settings(crawler.settings) crawler.signals.connect(self.engine_started, signal=signals.engine_started) crawler.signals.connect(self.engine_stopped, signal=signals.engine_stopped) @classmethod - def from_crawler(cls, crawler): + def from_crawler(cls, crawler: Crawler) -> Self: return cls(crawler) - def get_virtual_size(self): - size = self.resource.getrusage(self.resource.RUSAGE_SELF).ru_maxrss + def get_virtual_size(self) -> int: + size: int = self.resource.getrusage(self.resource.RUSAGE_SELF).ru_maxrss if sys.platform != "darwin": # on macOS ru_maxrss is in bytes, on Linux it is in KB size *= 1024 return size - def engine_started(self): + def engine_started(self) -> None: + assert self.crawler.stats self.crawler.stats.set_value("memusage/startup", self.get_virtual_size()) - self.tasks = [] + self.tasks: list[task.LoopingCall] = [] tsk = task.LoopingCall(self.update) self.tasks.append(tsk) tsk.start(self.check_interval, now=True) @@ -67,15 +79,18 @@ class MemoryUsage: self.tasks.append(tsk) tsk.start(self.check_interval, now=True) - def engine_stopped(self): + def engine_stopped(self) -> None: for tsk in self.tasks: if tsk.running: tsk.stop() - def update(self): + def update(self) -> None: + assert self.crawler.stats self.crawler.stats.max_value("memusage/max", self.get_virtual_size()) - def _check_limit(self): + def _check_limit(self) -> None: + assert self.crawler.engine + assert self.crawler.stats peak_mem_usage = self.get_virtual_size() if peak_mem_usage > self.limit: self.crawler.stats.set_value("memusage/limit_reached", 1) @@ -105,9 +120,10 @@ class MemoryUsage: {"virtualsize": peak_mem_usage / 1024 / 1024}, ) - def _check_warning(self): + def _check_warning(self) -> None: if self.warned: # warn only once return + assert self.crawler.stats if self.get_virtual_size() > self.warning: self.crawler.stats.set_value("memusage/warning_reached", 1) mem = self.warning / 1024 / 1024 @@ -125,8 +141,10 @@ class MemoryUsage: self.crawler.stats.set_value("memusage/warning_notified", 1) self.warned = True - def _send_report(self, rcpts, subject): + def _send_report(self, rcpts: list[str], subject: str) -> None: """send notification mail with some additional useful info""" + assert self.crawler.engine + assert self.crawler.stats stats = self.crawler.stats s = f"Memory usage at engine startup : {stats.get_value('memusage/startup') / 1024 / 1024}M\r\n" s += f"Maximum memory usage : {stats.get_value('memusage/max') / 1024 / 1024}M\r\n" diff --git a/scrapy/extensions/periodic_log.py b/scrapy/extensions/periodic_log.py index 2d557f123..f2e3782a4 100644 --- a/scrapy/extensions/periodic_log.py +++ b/scrapy/extensions/periodic_log.py @@ -1,12 +1,24 @@ +from __future__ import annotations + import logging from datetime import datetime, timezone +from json import JSONEncoder +from typing import TYPE_CHECKING, Any from twisted.internet import task -from scrapy import signals +from scrapy import Spider, signals from scrapy.exceptions import NotConfigured from scrapy.utils.serialize import ScrapyJSONEncoder +if TYPE_CHECKING: + # typing.Self requires Python 3.11 + from typing_extensions import Self + + from scrapy.crawler import Crawler + from scrapy.statscollectors import StatsCollector + + logger = logging.getLogger(__name__) @@ -15,32 +27,34 @@ class PeriodicLog: def __init__( self, - stats, - interval=60.0, - ext_stats={}, - ext_delta={}, - ext_timing_enabled=False, + stats: StatsCollector, + interval: float = 60.0, + ext_stats: dict[str, Any] = {}, + ext_delta: dict[str, Any] = {}, + ext_timing_enabled: bool = False, ): - self.stats = stats - self.interval = interval - self.multiplier = 60.0 / self.interval - self.task = None - self.encoder = ScrapyJSONEncoder(sort_keys=True, indent=4) - self.ext_stats_enabled = bool(ext_stats) - self.ext_stats_include = ext_stats.get("include", []) - self.ext_stats_exclude = ext_stats.get("exclude", []) - self.ext_delta_enabled = bool(ext_delta) - self.ext_delta_include = ext_delta.get("include", []) - self.ext_delta_exclude = ext_delta.get("exclude", []) - self.ext_timing_enabled = ext_timing_enabled + self.stats: StatsCollector = stats + self.interval: float = interval + self.multiplier: float = 60.0 / self.interval + self.task: task.LoopingCall | None = None + self.encoder: JSONEncoder = ScrapyJSONEncoder(sort_keys=True, indent=4) + self.ext_stats_enabled: bool = bool(ext_stats) + self.ext_stats_include: list[str] = ext_stats.get("include", []) + self.ext_stats_exclude: list[str] = ext_stats.get("exclude", []) + self.ext_delta_enabled: bool = bool(ext_delta) + self.ext_delta_include: list[str] = ext_delta.get("include", []) + self.ext_delta_exclude: list[str] = ext_delta.get("exclude", []) + self.ext_timing_enabled: bool = ext_timing_enabled @classmethod - def from_crawler(cls, crawler): - interval = crawler.settings.getfloat("LOGSTATS_INTERVAL") + def from_crawler(cls, crawler: Crawler) -> Self: + interval: float = crawler.settings.getfloat("LOGSTATS_INTERVAL") if not interval: raise NotConfigured try: - ext_stats = crawler.settings.getdict("PERIODIC_LOG_STATS") + ext_stats: dict[str, Any] | None = crawler.settings.getdict( + "PERIODIC_LOG_STATS" + ) except (TypeError, ValueError): ext_stats = ( {"enabled": True} @@ -48,7 +62,9 @@ class PeriodicLog: else None ) try: - ext_delta = crawler.settings.getdict("PERIODIC_LOG_DELTA") + ext_delta: dict[str, Any] | None = crawler.settings.getdict( + "PERIODIC_LOG_DELTA" + ) except (TypeError, ValueError): ext_delta = ( {"enabled": True} @@ -56,11 +72,14 @@ class PeriodicLog: else None ) - ext_timing_enabled = crawler.settings.getbool( + ext_timing_enabled: bool = crawler.settings.getbool( "PERIODIC_LOG_TIMING_ENABLED", False ) if not (ext_stats or ext_delta or ext_timing_enabled): raise NotConfigured + assert crawler.stats + assert ext_stats is not None + assert ext_delta is not None o = cls( crawler.stats, interval, @@ -72,16 +91,16 @@ class PeriodicLog: crawler.signals.connect(o.spider_closed, signal=signals.spider_closed) return o - def spider_opened(self, spider): - self.time_prev = datetime.now(tz=timezone.utc) - self.delta_prev = {} - self.stats_prev = {} + def spider_opened(self, spider: Spider) -> None: + self.time_prev: datetime = datetime.now(tz=timezone.utc) + self.delta_prev: dict[str, int | float] = {} + self.stats_prev: dict[str, int | float] = {} self.task = task.LoopingCall(self.log) self.task.start(self.interval) - def log(self): - data = {} + def log(self) -> None: + data: dict[str, Any] = {} if self.ext_timing_enabled: data.update(self.log_timing()) if self.ext_delta_enabled: @@ -90,8 +109,8 @@ class PeriodicLog: data.update(self.log_crawler_stats()) logger.info(self.encoder.encode(data)) - def log_delta(self): - num_stats = { + def log_delta(self) -> dict[str, Any]: + num_stats: dict[str, int | float] = { k: v for k, v in self.stats._stats.items() if isinstance(v, (int, float)) @@ -101,7 +120,7 @@ class PeriodicLog: self.delta_prev = num_stats return {"delta": delta} - def log_timing(self): + def log_timing(self) -> dict[str, Any]: now = datetime.now(tz=timezone.utc) time = { "log_interval": self.interval, @@ -113,7 +132,7 @@ class PeriodicLog: self.time_prev = now return {"time": time} - def log_crawler_stats(self): + def log_crawler_stats(self) -> dict[str, Any]: stats = { k: v for k, v in self.stats._stats.items() @@ -121,7 +140,9 @@ class PeriodicLog: } return {"stats": stats} - def param_allowed(self, stat_name, include, exclude): + def param_allowed( + self, stat_name: str, include: list[str], exclude: list[str] + ) -> bool: if not include and not exclude: return True for p in exclude: @@ -134,7 +155,7 @@ class PeriodicLog: return True return False - def spider_closed(self, spider, reason): + def spider_closed(self, spider: Spider, reason: str) -> None: self.log() if self.task and self.task.running: self.task.stop() diff --git a/scrapy/extensions/postprocessing.py b/scrapy/extensions/postprocessing.py index 79e3b1656..16067f82b 100644 --- a/scrapy/extensions/postprocessing.py +++ b/scrapy/extensions/postprocessing.py @@ -1,11 +1,12 @@ """ Extension for processing data before they are exported to feeds. """ + from bz2 import BZ2File from gzip import GzipFile from io import IOBase from lzma import LZMAFile -from typing import Any, BinaryIO, Dict, List +from typing import IO, Any, BinaryIO, cast from scrapy.utils.misc import load_object @@ -23,7 +24,7 @@ class GzipPlugin: See :py:class:`gzip.GzipFile` for more info about parameters. """ - def __init__(self, file: BinaryIO, feed_options: Dict[str, Any]) -> None: + def __init__(self, file: BinaryIO, feed_options: dict[str, Any]) -> None: self.file = file self.feed_options = feed_options compress_level = self.feed_options.get("gzip_compresslevel", 9) @@ -42,7 +43,6 @@ class GzipPlugin: def close(self) -> None: self.gzipfile.close() - self.file.close() class Bz2Plugin: @@ -56,7 +56,7 @@ class Bz2Plugin: See :py:class:`bz2.BZ2File` for more info about parameters. """ - def __init__(self, file: BinaryIO, feed_options: Dict[str, Any]) -> None: + def __init__(self, file: BinaryIO, feed_options: dict[str, Any]) -> None: self.file = file self.feed_options = feed_options compress_level = self.feed_options.get("bz2_compresslevel", 9) @@ -69,7 +69,6 @@ class Bz2Plugin: def close(self) -> None: self.bz2file.close() - self.file.close() class LZMAPlugin: @@ -89,7 +88,7 @@ class LZMAPlugin: See :py:class:`lzma.LZMAFile` for more info about parameters. """ - def __init__(self, file: BinaryIO, feed_options: Dict[str, Any]) -> None: + def __init__(self, file: BinaryIO, feed_options: dict[str, Any]) -> None: self.file = file self.feed_options = feed_options @@ -111,7 +110,6 @@ class LZMAPlugin: def close(self) -> None: self.lzmafile.close() - self.file.close() # io.IOBase is subclassed here, so that exporters can use the PostProcessingManager @@ -128,7 +126,7 @@ class PostProcessingManager(IOBase): """ def __init__( - self, plugins: List[Any], file: BinaryIO, feed_options: Dict[str, Any] + self, plugins: list[Any], file: IO[bytes], feed_options: dict[str, Any] ) -> None: self.plugins = self._load_plugins(plugins) self.file = file @@ -144,7 +142,7 @@ class PostProcessingManager(IOBase): :return: returns number of bytes written :rtype: int """ - return self.head_plugin.write(data) + return cast(int, self.head_plugin.write(data)) def tell(self) -> int: return self.file.tell() @@ -158,7 +156,7 @@ class PostProcessingManager(IOBase): def writable(self) -> bool: return True - def _load_plugins(self, plugins: List[Any]) -> List[Any]: + def _load_plugins(self, plugins: list[Any]) -> list[Any]: plugins = [load_object(plugin) for plugin in plugins] return plugins diff --git a/scrapy/extensions/spiderstate.py b/scrapy/extensions/spiderstate.py index 929a3be70..642919be9 100644 --- a/scrapy/extensions/spiderstate.py +++ b/scrapy/extensions/spiderstate.py @@ -1,19 +1,28 @@ -import pickle -from pathlib import Path +from __future__ import annotations -from scrapy import signals +import pickle # nosec +from pathlib import Path +from typing import TYPE_CHECKING + +from scrapy import Spider, signals from scrapy.exceptions import NotConfigured from scrapy.utils.job import job_dir +if TYPE_CHECKING: + # typing.Self requires Python 3.11 + from typing_extensions import Self + + from scrapy.crawler import Crawler + class SpiderState: """Store and load spider state during a scraping job""" - def __init__(self, jobdir=None): - self.jobdir = jobdir + def __init__(self, jobdir: str | None = None): + self.jobdir: str | None = jobdir @classmethod - def from_crawler(cls, crawler): + def from_crawler(cls, crawler: Crawler) -> Self: jobdir = job_dir(crawler.settings) if not jobdir: raise NotConfigured @@ -23,18 +32,20 @@ class SpiderState: crawler.signals.connect(obj.spider_opened, signal=signals.spider_opened) return obj - def spider_closed(self, spider): + def spider_closed(self, spider: Spider) -> None: if self.jobdir: with Path(self.statefn).open("wb") as f: + assert hasattr(spider, "state") # set in spider_opened pickle.dump(spider.state, f, protocol=4) - def spider_opened(self, spider): + def spider_opened(self, spider: Spider) -> None: if self.jobdir and Path(self.statefn).exists(): with Path(self.statefn).open("rb") as f: - spider.state = pickle.load(f) + spider.state = pickle.load(f) # type: ignore[attr-defined] # nosec else: - spider.state = {} + spider.state = {} # type: ignore[attr-defined] @property def statefn(self) -> str: + assert self.jobdir return str(Path(self.jobdir, "spider.state")) diff --git a/scrapy/extensions/statsmailer.py b/scrapy/extensions/statsmailer.py index 58610c25e..600eebcf2 100644 --- a/scrapy/extensions/statsmailer.py +++ b/scrapy/extensions/statsmailer.py @@ -4,28 +4,42 @@ StatsMailer extension sends an email when a spider finishes scraping. Use STATSMAILER_RCPTS setting to enable and give the recipient mail address """ -from scrapy import signals +from __future__ import annotations + +from typing import TYPE_CHECKING + +from scrapy import Spider, signals from scrapy.exceptions import NotConfigured from scrapy.mail import MailSender +if TYPE_CHECKING: + from twisted.internet.defer import Deferred + + # typing.Self requires Python 3.11 + from typing_extensions import Self + + from scrapy.crawler import Crawler + from scrapy.statscollectors import StatsCollector + class StatsMailer: - def __init__(self, stats, recipients, mail): - self.stats = stats - self.recipients = recipients - self.mail = mail + def __init__(self, stats: StatsCollector, recipients: list[str], mail: MailSender): + self.stats: StatsCollector = stats + self.recipients: list[str] = recipients + self.mail: MailSender = mail @classmethod - def from_crawler(cls, crawler): - recipients = crawler.settings.getlist("STATSMAILER_RCPTS") + def from_crawler(cls, crawler: Crawler) -> Self: + recipients: list[str] = crawler.settings.getlist("STATSMAILER_RCPTS") if not recipients: raise NotConfigured - mail = MailSender.from_settings(crawler.settings) + mail: MailSender = MailSender.from_settings(crawler.settings) + assert crawler.stats o = cls(crawler.stats, recipients, mail) crawler.signals.connect(o.spider_closed, signal=signals.spider_closed) return o - def spider_closed(self, spider): + def spider_closed(self, spider: Spider) -> Deferred[None] | None: spider_stats = self.stats.get_stats(spider) body = "Global stats\n\n" body += "\n".join(f"{k:<50} : {v}" for k, v in self.stats.get_stats().items()) diff --git a/scrapy/extensions/telnet.py b/scrapy/extensions/telnet.py index c92b7f5fe..07dc5880b 100644 --- a/scrapy/extensions/telnet.py +++ b/scrapy/extensions/telnet.py @@ -4,22 +4,16 @@ Scrapy Telnet Console extension See documentation in docs/topics/telnetconsole.rst """ +from __future__ import annotations + import binascii import logging import os import pprint -import traceback +from typing import TYPE_CHECKING, Any from twisted.internet import protocol - -try: - from twisted.conch import manhole, telnet - from twisted.conch.insults import insults - - TWISTED_CONCH_AVAILABLE = True -except (ImportError, SyntaxError): - _TWISTED_CONCH_TRACEBACK = traceback.format_exc() - TWISTED_CONCH_AVAILABLE = False +from twisted.internet.tcp import Port from scrapy import signals from scrapy.exceptions import NotConfigured @@ -28,6 +22,15 @@ from scrapy.utils.engine import print_engine_status from scrapy.utils.reactor import listen_tcp from scrapy.utils.trackref import print_live_refs +if TYPE_CHECKING: + from twisted.conch import telnet + + # typing.Self requires Python 3.11 + from typing_extensions import Self + + from scrapy.crawler import Crawler + + logger = logging.getLogger(__name__) # signal to update telnet variables @@ -36,22 +39,18 @@ update_telnet_vars = object() class TelnetConsole(protocol.ServerFactory): - def __init__(self, crawler): + def __init__(self, crawler: Crawler): if not crawler.settings.getbool("TELNETCONSOLE_ENABLED"): raise NotConfigured - if not TWISTED_CONCH_AVAILABLE: - raise NotConfigured( - "TELNETCONSOLE_ENABLED setting is True but required twisted " - "modules failed to import:\n" + _TWISTED_CONCH_TRACEBACK - ) - self.crawler = crawler - self.noisy = False - self.portrange = [ + + self.crawler: Crawler = crawler + self.noisy: bool = False + self.portrange: list[int] = [ int(x) for x in crawler.settings.getlist("TELNETCONSOLE_PORT") ] - self.host = crawler.settings["TELNETCONSOLE_HOST"] - self.username = crawler.settings["TELNETCONSOLE_USERNAME"] - self.password = crawler.settings["TELNETCONSOLE_PASSWORD"] + self.host: str = crawler.settings["TELNETCONSOLE_HOST"] + self.username: str = crawler.settings["TELNETCONSOLE_USERNAME"] + self.password: str = crawler.settings["TELNETCONSOLE_PASSWORD"] if not self.password: self.password = binascii.hexlify(os.urandom(8)).decode("utf8") @@ -61,11 +60,11 @@ class TelnetConsole(protocol.ServerFactory): self.crawler.signals.connect(self.stop_listening, signals.engine_stopped) @classmethod - def from_crawler(cls, crawler): + def from_crawler(cls, crawler: Crawler) -> Self: return cls(crawler) - def start_listening(self): - self.port = listen_tcp(self.portrange, self.host, self) + def start_listening(self) -> None: + self.port: Port = listen_tcp(self.portrange, self.host, self) h = self.port.getHost() logger.info( "Telnet console listening on %(host)s:%(port)d", @@ -73,10 +72,14 @@ class TelnetConsole(protocol.ServerFactory): extra={"crawler": self.crawler}, ) - def stop_listening(self): + def stop_listening(self) -> None: self.port.stopListening() - def protocol(self): + def protocol(self) -> telnet.TelnetTransport: # type: ignore[override] + # these import twisted.internet.reactor + from twisted.conch import manhole, telnet + from twisted.conch.insults import insults + class Portal: """An implementation of IPortal""" @@ -95,9 +98,10 @@ class TelnetConsole(protocol.ServerFactory): return telnet.TelnetTransport(telnet.AuthenticatingTelnetProtocol, Portal()) - def _get_telnet_vars(self): + def _get_telnet_vars(self) -> dict[str, Any]: # Note: if you add entries here also update topics/telnetconsole.rst - telnet_vars = { + assert self.crawler.engine + telnet_vars: dict[str, Any] = { "engine": self.crawler.engine, "spider": self.crawler.engine.spider, "slot": self.crawler.engine.slot, diff --git a/scrapy/extensions/throttle.py b/scrapy/extensions/throttle.py index 396800775..d4b4f0e9d 100644 --- a/scrapy/extensions/throttle.py +++ b/scrapy/extensions/throttle.py @@ -1,51 +1,70 @@ -import logging +from __future__ import annotations -from scrapy import signals +import logging +from typing import TYPE_CHECKING + +from scrapy import Request, Spider, signals from scrapy.exceptions import NotConfigured +if TYPE_CHECKING: + # typing.Self requires Python 3.11 + from typing_extensions import Self + + from scrapy.core.downloader import Slot + from scrapy.crawler import Crawler + from scrapy.http import Response + + logger = logging.getLogger(__name__) class AutoThrottle: - def __init__(self, crawler): - self.crawler = crawler + def __init__(self, crawler: Crawler): + self.crawler: Crawler = crawler if not crawler.settings.getbool("AUTOTHROTTLE_ENABLED"): raise NotConfigured - self.debug = crawler.settings.getbool("AUTOTHROTTLE_DEBUG") - self.target_concurrency = crawler.settings.getfloat( + self.debug: bool = crawler.settings.getbool("AUTOTHROTTLE_DEBUG") + self.target_concurrency: float = crawler.settings.getfloat( "AUTOTHROTTLE_TARGET_CONCURRENCY" ) + if self.target_concurrency <= 0.0: + raise NotConfigured( + f"AUTOTHROTTLE_TARGET_CONCURRENCY " + f"({self.target_concurrency!r}) must be higher than 0." + ) crawler.signals.connect(self._spider_opened, signal=signals.spider_opened) crawler.signals.connect( self._response_downloaded, signal=signals.response_downloaded ) @classmethod - def from_crawler(cls, crawler): + def from_crawler(cls, crawler: Crawler) -> Self: return cls(crawler) - def _spider_opened(self, spider): + def _spider_opened(self, spider: Spider) -> None: self.mindelay = self._min_delay(spider) self.maxdelay = self._max_delay(spider) - spider.download_delay = self._start_delay(spider) + spider.download_delay = self._start_delay(spider) # type: ignore[attr-defined] - def _min_delay(self, spider): + def _min_delay(self, spider: Spider) -> float: s = self.crawler.settings return getattr(spider, "download_delay", s.getfloat("DOWNLOAD_DELAY")) - def _max_delay(self, spider): + def _max_delay(self, spider: Spider) -> float: return self.crawler.settings.getfloat("AUTOTHROTTLE_MAX_DELAY") - def _start_delay(self, spider): + def _start_delay(self, spider: Spider) -> float: return max( self.mindelay, self.crawler.settings.getfloat("AUTOTHROTTLE_START_DELAY") ) - def _response_downloaded(self, response, request, spider): + def _response_downloaded( + self, response: Response, request: Request, spider: Spider + ) -> None: key, slot = self._get_slot(request, spider) latency = request.meta.get("download_latency") - if latency is None or slot is None: + if latency is None or slot is None or slot.throttle is False: return olddelay = slot.delay @@ -69,11 +88,16 @@ class AutoThrottle: extra={"spider": spider}, ) - def _get_slot(self, request, spider): - key = request.meta.get("download_slot") + def _get_slot( + self, request: Request, spider: Spider + ) -> tuple[str | None, Slot | None]: + key: str | None = request.meta.get("download_slot") + if key is None: + return None, None + assert self.crawler.engine return key, self.crawler.engine.downloader.slots.get(key) - def _adjust_delay(self, slot, latency, response): + def _adjust_delay(self, slot: Slot, latency: float, response: Response) -> None: """Define delay adjustment policy""" # If a server needs `latency` seconds to respond then diff --git a/scrapy/http/cookies.py b/scrapy/http/cookies.py index 2595f328e..56941ad51 100644 --- a/scrapy/http/cookies.py +++ b/scrapy/http/cookies.py @@ -1,36 +1,50 @@ +from __future__ import annotations + import re import time from http.cookiejar import Cookie from http.cookiejar import CookieJar as _CookieJar -from http.cookiejar import DefaultCookiePolicy -from typing import Sequence +from http.cookiejar import CookiePolicy, DefaultCookiePolicy +from typing import TYPE_CHECKING, Any, cast -from scrapy import Request -from scrapy.http import Response from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.python import to_unicode +if TYPE_CHECKING: + from collections.abc import Iterator, Sequence + + # typing.Self requires Python 3.11 + from typing_extensions import Self + + from scrapy import Request + from scrapy.http import Response + + # Defined in the http.cookiejar module, but undocumented: # https://github.com/python/cpython/blob/v3.9.0/Lib/http/cookiejar.py#L527 IPV4_RE = re.compile(r"\.\d+$", re.ASCII) class CookieJar: - def __init__(self, policy=None, check_expired_frequency=10000): - self.policy = policy or DefaultCookiePolicy() - self.jar = _CookieJar(self.policy) - self.jar._cookies_lock = _DummyLock() - self.check_expired_frequency = check_expired_frequency - self.processed = 0 + def __init__( + self, + policy: CookiePolicy | None = None, + check_expired_frequency: int = 10000, + ): + self.policy: CookiePolicy = policy or DefaultCookiePolicy() + self.jar: _CookieJar = _CookieJar(self.policy) + self.jar._cookies_lock = _DummyLock() # type: ignore[attr-defined] + self.check_expired_frequency: int = check_expired_frequency + self.processed: int = 0 - def extract_cookies(self, response, request): + def extract_cookies(self, response: Response, request: Request) -> None: wreq = WrappedRequest(request) wrsp = WrappedResponse(response) - return self.jar.extract_cookies(wrsp, wreq) + self.jar.extract_cookies(wrsp, wreq) # type: ignore[arg-type] def add_cookie_header(self, request: Request) -> None: wreq = WrappedRequest(request) - self.policy._now = self.jar._now = int(time.time()) + self.policy._now = self.jar._now = int(time.time()) # type: ignore[attr-defined] # the cookiejar implementation iterates through all domains # instead we restrict to potential matches on the domain @@ -47,10 +61,10 @@ class CookieJar: cookies = [] for host in hosts: - if host in self.jar._cookies: - cookies += self.jar._cookies_for_domain(host, wreq) + if host in self.jar._cookies: # type: ignore[attr-defined] + cookies += self.jar._cookies_for_domain(host, wreq) # type: ignore[attr-defined] - attrs = self.jar._cookie_attrs(cookies) + attrs = self.jar._cookie_attrs(cookies) # type: ignore[attr-defined] if attrs: if not wreq.has_header("Cookie"): wreq.add_unredirected_header("Cookie", "; ".join(attrs)) @@ -61,37 +75,42 @@ class CookieJar: self.jar.clear_expired_cookies() @property - def _cookies(self): - return self.jar._cookies + def _cookies(self) -> dict[str, dict[str, dict[str, Cookie]]]: + return self.jar._cookies # type: ignore[attr-defined,no-any-return] - def clear_session_cookies(self, *args, **kwargs): - return self.jar.clear_session_cookies(*args, **kwargs) + def clear_session_cookies(self) -> None: + return self.jar.clear_session_cookies() - def clear(self, domain=None, path=None, name=None): - return self.jar.clear(domain, path, name) + def clear( + self, + domain: str | None = None, + path: str | None = None, + name: str | None = None, + ) -> None: + self.jar.clear(domain, path, name) - def __iter__(self): + def __iter__(self) -> Iterator[Cookie]: return iter(self.jar) - def __len__(self): + def __len__(self) -> int: return len(self.jar) - def set_policy(self, pol): - return self.jar.set_policy(pol) + def set_policy(self, pol: CookiePolicy) -> None: + self.jar.set_policy(pol) def make_cookies(self, response: Response, request: Request) -> Sequence[Cookie]: wreq = WrappedRequest(request) wrsp = WrappedResponse(response) - return self.jar.make_cookies(wrsp, wreq) + return self.jar.make_cookies(wrsp, wreq) # type: ignore[arg-type] - def set_cookie(self, cookie): + def set_cookie(self, cookie: Cookie) -> None: self.jar.set_cookie(cookie) def set_cookie_if_ok(self, cookie: Cookie, request: Request) -> None: - self.jar.set_cookie_if_ok(cookie, WrappedRequest(request)) + self.jar.set_cookie_if_ok(cookie, WrappedRequest(request)) # type: ignore[arg-type] -def potential_domain_matches(domain): +def potential_domain_matches(domain: str) -> list[str]: """Potential domain matches for a cookie >>> potential_domain_matches('www.example.com') @@ -111,10 +130,10 @@ def potential_domain_matches(domain): class _DummyLock: - def acquire(self): + def acquire(self) -> None: pass - def release(self): + def release(self) -> None: pass @@ -124,19 +143,19 @@ class WrappedRequest: see http://docs.python.org/library/urllib2.html#urllib2.Request """ - def __init__(self, request): + def __init__(self, request: Request): self.request = request - def get_full_url(self): + def get_full_url(self) -> str: return self.request.url - def get_host(self): + def get_host(self) -> str: return urlparse_cached(self.request).netloc - def get_type(self): + def get_type(self) -> str: return urlparse_cached(self.request).scheme - def is_unverifiable(self): + def is_unverifiable(self) -> bool: """Unverifiable should indicate whether the request is unverifiable, as defined by RFC 2965. It defaults to False. An unverifiable request is one whose URL the user did not have the @@ -144,35 +163,36 @@ class WrappedRequest: HTML document, and the user had no option to approve the automatic fetching of the image, this should be true. """ - return self.request.meta.get("is_unverifiable", False) + return cast(bool, self.request.meta.get("is_unverifiable", False)) @property - def full_url(self): + def full_url(self) -> str: return self.get_full_url() @property - def host(self): + def host(self) -> str: return self.get_host() @property - def type(self): + def type(self) -> str: return self.get_type() @property - def unverifiable(self): + def unverifiable(self) -> bool: return self.is_unverifiable() @property - def origin_req_host(self): - return urlparse_cached(self.request).hostname + def origin_req_host(self) -> str: + return cast(str, urlparse_cached(self.request).hostname) - def has_header(self, name): + def has_header(self, name: str) -> bool: return name in self.request.headers - def get_header(self, name, default=None): - return to_unicode(self.request.headers.get(name, default), errors="replace") + def get_header(self, name: str, default: str | None = None) -> str | None: + value = self.request.headers.get(name, default) + return to_unicode(value, errors="replace") if value is not None else None - def header_items(self): + def header_items(self) -> list[tuple[str, list[str]]]: return [ ( to_unicode(k, errors="replace"), @@ -181,18 +201,18 @@ class WrappedRequest: for k, v in self.request.headers.items() ] - def add_unredirected_header(self, name, value): + def add_unredirected_header(self, name: str, value: str) -> None: self.request.headers.appendlist(name, value) class WrappedResponse: - def __init__(self, response): + def __init__(self, response: Response): self.response = response - def info(self): + def info(self) -> Self: return self - def get_all(self, name, default=None): + def get_all(self, name: str, default: Any = None) -> list[str]: return [ to_unicode(v, errors="replace") for v in self.response.headers.getlist(name) ] diff --git a/scrapy/http/headers.py b/scrapy/http/headers.py index 21eb9fb73..29ba9533b 100644 --- a/scrapy/http/headers.py +++ b/scrapy/http/headers.py @@ -1,18 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import ( - TYPE_CHECKING, - Any, - AnyStr, - Dict, - Iterable, - List, - Optional, - Tuple, - Union, - cast, -) +from typing import TYPE_CHECKING, Any, AnyStr, Union, cast from w3lib.http import headers_dict_to_raw @@ -20,6 +9,8 @@ from scrapy.utils.datatypes import CaseInsensitiveDict, CaselessDict from scrapy.utils.python import to_unicode if TYPE_CHECKING: + from collections.abc import Iterable + # typing.Self requires Python 3.11 from typing_extensions import Self @@ -34,17 +25,17 @@ class Headers(CaselessDict): def __init__( self, - seq: Union[Mapping[AnyStr, Any], Iterable[Tuple[AnyStr, Any]], None] = None, + seq: Mapping[AnyStr, Any] | Iterable[tuple[AnyStr, Any]] | None = None, encoding: str = "utf-8", ): self.encoding: str = encoding super().__init__(seq) def update( # type: ignore[override] - self, seq: Union[Mapping[AnyStr, Any], Iterable[Tuple[AnyStr, Any]]] + self, seq: Mapping[AnyStr, Any] | Iterable[tuple[AnyStr, Any]] ) -> None: seq = seq.items() if isinstance(seq, Mapping) else seq - iseq: Dict[bytes, List[bytes]] = {} + iseq: dict[bytes, list[bytes]] = {} for k, v in seq: iseq.setdefault(self.normkey(k), []).extend(self.normvalue(v)) super().update(iseq) @@ -53,7 +44,7 @@ class Headers(CaselessDict): """Normalize key to bytes""" return self._tobytes(key.title()) - def normvalue(self, value: Union[_RawValueT, Iterable[_RawValueT]]) -> List[bytes]: + def normvalue(self, value: _RawValueT | Iterable[_RawValueT]) -> list[bytes]: """Normalize values to bytes""" _value: Iterable[_RawValueT] if value is None: @@ -76,21 +67,21 @@ class Headers(CaselessDict): return str(x).encode(self.encoding) raise TypeError(f"Unsupported value type: {type(x)}") - def __getitem__(self, key: AnyStr) -> Optional[bytes]: + 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) -> Optional[bytes]: + 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]: + 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) @@ -109,15 +100,16 @@ class Headers(CaselessDict): lst.extend(self.normvalue(value)) self[key] = lst - def items(self) -> Iterable[Tuple[bytes, List[bytes]]]: # type: ignore[override] + def items(self) -> Iterable[tuple[bytes, list[bytes]]]: # type: ignore[override] return ((k, self.getlist(k)) for k in self.keys()) - def values(self) -> List[Optional[bytes]]: # type: ignore[override] - return [self[k] for k in self.keys()] + def values(self) -> list[bytes | None]: # type: ignore[override] + return [ + self[k] for k in self.keys() # pylint: disable=consider-using-dict-items + ] def to_string(self) -> bytes: - # cast() can be removed if the headers_dict_to_raw() hint is improved - return cast(bytes, headers_dict_to_raw(self)) + return headers_dict_to_raw(self) def to_unicode_dict(self) -> CaseInsensitiveDict: """Return headers as a CaseInsensitiveDict with str keys diff --git a/scrapy/http/request/__init__.py b/scrapy/http/request/__init__.py index a1c5a5e51..ed225555c 100644 --- a/scrapy/http/request/__init__.py +++ b/scrapy/http/request/__init__.py @@ -4,22 +4,19 @@ requests in Scrapy. See documentation in docs/topics/request-response.rst """ + +from __future__ import annotations + import inspect from typing import ( + TYPE_CHECKING, Any, AnyStr, - Callable, - Dict, - Iterable, - List, - Mapping, NoReturn, - Optional, - Tuple, - Type, + TypedDict, TypeVar, Union, - cast, + overload, ) from w3lib.url import safe_url_string @@ -31,6 +28,31 @@ from scrapy.utils.python import to_bytes from scrapy.utils.trackref import object_ref from scrapy.utils.url import escape_ajax +if TYPE_CHECKING: + from collections.abc import Callable, Iterable, Mapping + + 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 scrapy.http import Response + + CallbackT = Callable[Concatenate[Response, ...], Any] + + +class VerboseCookie(TypedDict): + name: str + value: str + domain: NotRequired[str] + path: NotRequired[str] + secure: NotRequired[bool] + + +CookiesT = Union[dict[str, str], list[VerboseCookie]] + + RequestTypeVar = TypeVar("RequestTypeVar", bound="Request") @@ -63,7 +85,7 @@ class Request(object_ref): executed by the Downloader, thus generating a :class:`Response`. """ - attributes: Tuple[str, ...] = ( + attributes: tuple[str, ...] = ( "url", "callback", "method", @@ -89,18 +111,18 @@ class Request(object_ref): def __init__( self, url: str, - callback: Optional[Callable] = None, + callback: CallbackT | None = None, method: str = "GET", - headers: Union[Mapping[AnyStr, Any], Iterable[Tuple[AnyStr, Any]], None] = None, - body: Optional[Union[bytes, str]] = None, - cookies: Optional[Union[dict, List[dict]]] = None, - meta: Optional[Dict[str, Any]] = None, + headers: Mapping[AnyStr, Any] | Iterable[tuple[AnyStr, Any]] | None = None, + body: bytes | str | None = None, + cookies: CookiesT | None = None, + meta: dict[str, Any] | None = None, encoding: str = "utf-8", priority: int = 0, dont_filter: bool = False, - errback: Optional[Callable] = None, - flags: Optional[List[str]] = None, - cb_kwargs: Optional[Dict[str, Any]] = None, + errback: Callable[[Failure], Any] | None = None, + flags: list[str] | None = None, + cb_kwargs: dict[str, Any] | None = None, ) -> None: self._encoding: str = encoding # this one has to be set first self.method: str = str(method).upper() @@ -116,27 +138,25 @@ class Request(object_ref): ) if not (callable(errback) or errback is None): raise TypeError(f"errback must be a callable, got {type(errback).__name__}") - self.callback: Optional[Callable] = callback - self.errback: Optional[Callable] = errback + self.callback: CallbackT | None = callback + self.errback: Callable[[Failure], Any] | None = errback - self.cookies: Union[dict, List[dict]] = cookies or {} + self.cookies: CookiesT = cookies or {} self.headers: Headers = Headers(headers or {}, encoding=encoding) self.dont_filter: bool = dont_filter - self._meta: Optional[Dict[str, Any]] = dict(meta) if meta else None - self._cb_kwargs: Optional[Dict[str, Any]] = ( - dict(cb_kwargs) if cb_kwargs else None - ) - self.flags: List[str] = [] if flags is None else list(flags) + self._meta: dict[str, Any] | None = dict(meta) if meta else None + self._cb_kwargs: dict[str, Any] | None = dict(cb_kwargs) if cb_kwargs else None + self.flags: list[str] = [] if flags is None else list(flags) @property - def cb_kwargs(self) -> Dict[str, Any]: + def cb_kwargs(self) -> dict[str, Any]: if self._cb_kwargs is None: self._cb_kwargs = {} return self._cb_kwargs @property - def meta(self) -> Dict[str, Any]: + def meta(self) -> dict[str, Any]: if self._meta is None: self._meta = {} return self._meta @@ -163,7 +183,7 @@ class Request(object_ref): def body(self) -> bytes: return self._body - def _set_body(self, body: Optional[Union[str, bytes]]) -> None: + def _set_body(self, body: str | bytes | None) -> None: self._body = b"" if body is None else to_bytes(body, self.encoding) @property @@ -173,23 +193,34 @@ class Request(object_ref): def __repr__(self) -> str: return f"<{self.method} {self.url}>" - def copy(self) -> "Request": + def copy(self) -> Self: return self.replace() - def replace(self, *args: Any, **kwargs: Any) -> "Request": + @overload + def replace( + self, *args: Any, cls: type[RequestTypeVar], **kwargs: Any + ) -> RequestTypeVar: ... + + @overload + def replace(self, *args: Any, cls: None = None, **kwargs: Any) -> Self: ... + + def replace( + self, *args: Any, cls: type[Request] | None = None, **kwargs: Any + ) -> Request: """Create a new Request with the same attributes except for those given new values""" for x in self.attributes: kwargs.setdefault(x, getattr(self, x)) - cls = kwargs.pop("cls", self.__class__) - return cast(Request, cls(*args, **kwargs)) + if cls is None: + cls = self.__class__ + return cls(*args, **kwargs) @classmethod def from_curl( - cls: Type[RequestTypeVar], + cls, curl_command: str, ignore_unknown_options: bool = True, **kwargs: Any, - ) -> RequestTypeVar: + ) -> Self: """Create a Request object from a string containing a `cURL `_ command. It populates the HTTP method, the URL, the headers, the cookies and the body. It accepts the same @@ -221,7 +252,7 @@ class Request(object_ref): request_kwargs.update(kwargs) return cls(**request_kwargs) - def to_dict(self, *, spider: Optional["scrapy.Spider"] = None) -> Dict[str, Any]: + def to_dict(self, *, spider: scrapy.Spider | None = None) -> dict[str, Any]: """Return a dictionary containing the Request's data. Use :func:`~scrapy.utils.request.request_from_dict` to convert back into a :class:`~scrapy.Request` object. @@ -231,12 +262,16 @@ class Request(object_ref): """ d = { "url": self.url, # urls are safe (safe_string_url) - "callback": _find_method(spider, self.callback) - if callable(self.callback) - else self.callback, - "errback": _find_method(spider, self.errback) - if callable(self.errback) - else self.errback, + "callback": ( + _find_method(spider, self.callback) + if callable(self.callback) + else self.callback + ), + "errback": ( + _find_method(spider, self.errback) + if callable(self.errback) + else self.errback + ), "headers": dict(self.headers), } for attr in self.attributes: @@ -246,7 +281,7 @@ class Request(object_ref): return d -def _find_method(obj: Any, func: Callable) -> str: +def _find_method(obj: Any, func: Callable[..., Any]) -> str: """Helper function for Request.to_dict""" # Only instance methods contain ``__func__`` if obj and hasattr(func, "__func__"): diff --git a/scrapy/http/request/form.py b/scrapy/http/request/form.py index 0f80a0ab7..29743565d 100644 --- a/scrapy/http/request/form.py +++ b/scrapy/http/request/form.py @@ -7,32 +7,31 @@ See documentation in docs/topics/request-response.rst from __future__ import annotations -from typing import TYPE_CHECKING, Any, Iterable, List, Optional, Tuple, Union, cast +from collections.abc import Iterable +from typing import TYPE_CHECKING, Any, Optional, Union, cast from urllib.parse import urlencode, urljoin, urlsplit, urlunsplit -from lxml.html import ( - FormElement, - HTMLParser, - InputElement, - MultipleSelectOptions, - SelectElement, - TextareaElement, -) -from parsel.selector import create_root_node +from lxml.html import FormElement # nosec +from lxml.html import InputElement # nosec +from lxml.html import MultipleSelectOptions # nosec +from lxml.html import SelectElement # nosec +from lxml.html import TextareaElement # nosec from w3lib.html import strip_html5_whitespace from scrapy.http.request import Request -from scrapy.http.response.text import TextResponse from scrapy.utils.python import is_listlike, to_bytes -from scrapy.utils.response import get_base_url if TYPE_CHECKING: + # typing.Self requires Python 3.11 from typing_extensions import Self + from scrapy.http.response.text import TextResponse -FormdataKVType = Tuple[str, Union[str, Iterable[str]]] -FormdataType = Optional[Union[dict, List[FormdataKVType]]] + +FormdataVType = Union[str, Iterable[str]] +FormdataKVType = tuple[str, FormdataVType] +FormdataType = Optional[Union[dict[str, FormdataVType], list[FormdataKVType]]] class FormRequest(Request): @@ -63,14 +62,14 @@ class FormRequest(Request): def from_response( cls, response: TextResponse, - formname: Optional[str] = None, - formid: Optional[str] = None, + formname: str | None = None, + formid: str | None = None, formnumber: int = 0, formdata: FormdataType = None, - clickdata: Optional[dict] = None, + clickdata: dict[str, str | int] | None = None, dont_click: bool = False, - formxpath: Optional[str] = None, - formcss: Optional[str] = None, + formxpath: str | None = None, + formcss: str | None = None, **kwargs: Any, ) -> Self: kwargs.setdefault("encoding", response.encoding) @@ -93,7 +92,7 @@ class FormRequest(Request): return cls(url=url, method=method, formdata=formdata, **kwargs) -def _get_form_url(form: FormElement, url: Optional[str]) -> str: +def _get_form_url(form: FormElement, url: str | None) -> str: assert form.base_url is not None # typing if url is None: action = form.get("action") @@ -114,13 +113,13 @@ def _urlencode(seq: Iterable[FormdataKVType], enc: str) -> str: def _get_form( response: TextResponse, - formname: Optional[str], - formid: Optional[str], + formname: str | None, + formid: str | None, formnumber: int, - formxpath: Optional[str], + formxpath: str | None, ) -> FormElement: """Find the wanted form element within the given response.""" - root = create_root_node(response.text, HTMLParser, base_url=get_base_url(response)) + root = response.selector.root forms = root.xpath("//form") if not forms: raise ValueError(f"No
element found in {response}") @@ -161,8 +160,8 @@ def _get_inputs( form: FormElement, formdata: FormdataType, dont_click: bool, - clickdata: Optional[dict], -) -> List[FormdataKVType]: + clickdata: dict[str, str | int] | None, +) -> list[FormdataKVType]: """Return a list of key-value pairs for the inputs found in the given form.""" try: formdata_keys = dict(formdata or ()).keys() @@ -180,7 +179,7 @@ def _get_inputs( ' not(re:test(., "^(?:checkbox|radio)$", "i")))]]', namespaces={"re": "http://exslt.org/regular-expressions"}, ) - values: List[FormdataKVType] = [ + values: list[FormdataKVType] = [ (k, "" if v is None else v) for k, v in (_value(e) for e in inputs) if k and k not in formdata_keys @@ -191,16 +190,14 @@ def _get_inputs( if clickable and clickable[0] not in formdata and not clickable[0] is None: values.append(clickable) - if isinstance(formdata, dict): - formdata = formdata.items() # type: ignore[assignment] - - values.extend((k, v) for k, v in formdata if v is not None) + formdata_items = formdata.items() if isinstance(formdata, dict) else formdata + values.extend((k, v) for k, v in formdata_items if v is not None) return values def _value( - ele: Union[InputElement, SelectElement, TextareaElement] -) -> Tuple[Optional[str], Union[None, str, MultipleSelectOptions]]: + ele: InputElement | SelectElement | TextareaElement, +) -> tuple[str | None, str | MultipleSelectOptions | None]: n = ele.name v = ele.value if ele.tag == "select": @@ -209,8 +206,8 @@ def _value( def _select_value( - ele: SelectElement, n: Optional[str], v: Union[None, str, MultipleSelectOptions] -) -> Tuple[Optional[str], Union[None, str, MultipleSelectOptions]]: + ele: SelectElement, n: str | None, v: str | MultipleSelectOptions | None +) -> tuple[str | None, str | MultipleSelectOptions | None]: multiple = ele.multiple if v is None and not multiple: # Match browser behaviour on simple select tag without options selected @@ -221,8 +218,8 @@ def _select_value( def _get_clickable( - clickdata: Optional[dict], form: FormElement -) -> Optional[Tuple[str, str]]: + clickdata: dict[str, str | int] | None, form: FormElement +) -> tuple[str, str] | None: """ Returns the clickable element specified in clickdata, if the latter is given. If not, it returns the first @@ -248,6 +245,7 @@ def _get_clickable( # because that uniquely identifies the element nr = clickdata.get("nr", None) if nr is not None: + assert isinstance(nr, int) try: el = list(form.inputs)[nr] except IndexError: diff --git a/scrapy/http/request/json_request.py b/scrapy/http/request/json_request.py index 1dd9e6c87..289c60591 100644 --- a/scrapy/http/request/json_request.py +++ b/scrapy/http/request/json_request.py @@ -5,27 +5,33 @@ This module implements the JsonRequest class which is a more convenient class See documentation in docs/topics/request-response.rst """ +from __future__ import annotations + import copy import json import warnings -from typing import Any, Optional, Tuple +from typing import TYPE_CHECKING, Any, overload -from scrapy.http.request import Request +from scrapy.http.request import Request, RequestTypeVar + +if TYPE_CHECKING: + # typing.Self requires Python 3.11 + from typing_extensions import Self class JsonRequest(Request): - attributes: Tuple[str, ...] = Request.attributes + ("dumps_kwargs",) + attributes: tuple[str, ...] = Request.attributes + ("dumps_kwargs",) def __init__( - self, *args: Any, dumps_kwargs: Optional[dict] = None, **kwargs: Any + self, *args: Any, dumps_kwargs: dict[str, Any] | None = None, **kwargs: Any ) -> None: dumps_kwargs = copy.deepcopy(dumps_kwargs) if dumps_kwargs is not None else {} dumps_kwargs.setdefault("sort_keys", True) - self._dumps_kwargs = dumps_kwargs + self._dumps_kwargs: dict[str, Any] = dumps_kwargs body_passed = kwargs.get("body", None) is not None - data = kwargs.pop("data", None) - data_passed = data is not None + data: Any = kwargs.pop("data", None) + data_passed: bool = data is not None if body_passed and data_passed: warnings.warn("Both body and data passed. data will be ignored") @@ -41,21 +47,31 @@ class JsonRequest(Request): ) @property - def dumps_kwargs(self) -> dict: + def dumps_kwargs(self) -> dict[str, Any]: return self._dumps_kwargs - def replace(self, *args: Any, **kwargs: Any) -> Request: + @overload + def replace( + self, *args: Any, cls: type[RequestTypeVar], **kwargs: Any + ) -> RequestTypeVar: ... + + @overload + def replace(self, *args: Any, cls: None = None, **kwargs: Any) -> Self: ... + + def replace( + self, *args: Any, cls: type[Request] | None = None, **kwargs: Any + ) -> Request: body_passed = kwargs.get("body", None) is not None - data = kwargs.pop("data", None) - data_passed = data is not None + data: Any = kwargs.pop("data", None) + data_passed: bool = data is not None if body_passed and data_passed: warnings.warn("Both body and data passed. data will be ignored") elif not body_passed and data_passed: kwargs["body"] = self._dumps(data) - return super().replace(*args, **kwargs) + return super().replace(*args, cls=cls, **kwargs) - def _dumps(self, data: dict) -> str: + def _dumps(self, data: Any) -> str: """Convert to JSON""" return json.dumps(data, **self._dumps_kwargs) diff --git a/scrapy/http/request/rpc.py b/scrapy/http/request/rpc.py index bde860a66..01fe740a8 100644 --- a/scrapy/http/request/rpc.py +++ b/scrapy/http/request/rpc.py @@ -4,19 +4,26 @@ This module implements the XmlRpcRequest class which is a more convenient class See documentation in docs/topics/request-response.rst """ + +from __future__ import annotations + import xmlrpc.client as xmlrpclib -from typing import Any, Optional +from typing import Any + +import defusedxml.xmlrpc from scrapy.http.request import Request from scrapy.utils.python import get_func_args +defusedxml.xmlrpc.monkey_patch() + DUMPS_ARGS = get_func_args(xmlrpclib.dumps) class XmlRpcRequest(Request): - def __init__(self, *args: Any, encoding: Optional[str] = None, **kwargs: Any): + def __init__(self, *args: Any, encoding: str | None = None, **kwargs: Any): if "body" not in kwargs and "params" in kwargs: - kw = dict((k, kwargs.pop(k)) for k in DUMPS_ARGS if k in kwargs) + kw = {k: kwargs.pop(k) for k in DUMPS_ARGS if k in kwargs} kwargs["body"] = xmlrpclib.dumps(**kw) # spec defines that requests must use POST method diff --git a/scrapy/http/response/__init__.py b/scrapy/http/response/__init__.py index 6eae3e8b3..d50388548 100644 --- a/scrapy/http/response/__init__.py +++ b/scrapy/http/response/__init__.py @@ -4,28 +4,12 @@ responses in Scrapy. See documentation in docs/topics/request-response.rst """ + from __future__ import annotations -from ipaddress import IPv4Address, IPv6Address -from typing import ( - TYPE_CHECKING, - Any, - AnyStr, - Callable, - Dict, - Generator, - Iterable, - List, - Mapping, - Optional, - Tuple, - Union, - cast, -) +from typing import TYPE_CHECKING, Any, AnyStr, TypeVar, overload from urllib.parse import urljoin -from twisted.internet.ssl import Certificate - from scrapy.exceptions import NotSupported from scrapy.http.headers import Headers from scrapy.http.request import Request @@ -33,15 +17,28 @@ from scrapy.link import Link from scrapy.utils.trackref import object_ref if TYPE_CHECKING: + from collections.abc import Callable, Iterable, Mapping + from ipaddress import IPv4Address, IPv6Address + + from twisted.internet.ssl import Certificate + from twisted.python.failure import Failure + + # typing.Self requires Python 3.11 + from typing_extensions import Self + + from scrapy.http.request import CallbackT, CookiesT from scrapy.selector import SelectorList +ResponseTypeVar = TypeVar("ResponseTypeVar", bound="Response") + + class Response(object_ref): """An object that represents an HTTP response, which is usually downloaded (by the Downloader) and fed to the Spiders for processing. """ - attributes: Tuple[str, ...] = ( + attributes: tuple[str, ...] = ( "url", "status", "headers", @@ -63,26 +60,26 @@ class Response(object_ref): self, url: str, status: int = 200, - headers: Union[Mapping[AnyStr, Any], Iterable[Tuple[AnyStr, Any]], None] = None, + headers: Mapping[AnyStr, Any] | Iterable[tuple[AnyStr, Any]] | None = None, body: bytes = b"", - flags: Optional[List[str]] = None, - request: Optional[Request] = None, - certificate: Optional[Certificate] = None, - ip_address: Union[IPv4Address, IPv6Address, None] = None, - protocol: Optional[str] = None, + flags: list[str] | None = None, + request: Request | None = None, + certificate: Certificate | None = None, + ip_address: IPv4Address | IPv6Address | None = None, + protocol: str | None = None, ): self.headers: Headers = Headers(headers or {}) self.status: int = int(status) self._set_body(body) self._set_url(url) - self.request: Optional[Request] = request - self.flags: List[str] = [] if flags is None else list(flags) - self.certificate: Optional[Certificate] = certificate - self.ip_address: Union[IPv4Address, IPv6Address, None] = ip_address - self.protocol: Optional[str] = protocol + self.request: Request | None = request + self.flags: list[str] = [] if flags is None else list(flags) + self.certificate: Certificate | None = certificate + self.ip_address: IPv4Address | IPv6Address | None = ip_address + self.protocol: str | None = protocol @property - def cb_kwargs(self) -> Dict[str, Any]: + def cb_kwargs(self) -> dict[str, Any]: try: return self.request.cb_kwargs # type: ignore[union-attr] except AttributeError: @@ -92,7 +89,7 @@ class Response(object_ref): ) @property - def meta(self) -> Dict[str, Any]: + def meta(self) -> dict[str, Any]: try: return self.request.meta # type: ignore[union-attr] except AttributeError: @@ -117,7 +114,7 @@ class Response(object_ref): def body(self) -> bytes: return self._body - def _set_body(self, body: Optional[bytes]) -> None: + def _set_body(self, body: bytes | None) -> None: if body is None: self._body = b"" elif not isinstance(body, bytes): @@ -132,16 +129,27 @@ class Response(object_ref): def __repr__(self) -> str: return f"<{self.status} {self.url}>" - def copy(self) -> Response: + def copy(self) -> Self: """Return a copy of this Response""" return self.replace() - def replace(self, *args: Any, **kwargs: Any) -> Response: + @overload + def replace( + self, *args: Any, cls: type[ResponseTypeVar], **kwargs: Any + ) -> ResponseTypeVar: ... + + @overload + def replace(self, *args: Any, cls: None = None, **kwargs: Any) -> Self: ... + + def replace( + self, *args: Any, cls: type[Response] | None = None, **kwargs: Any + ) -> Response: """Create a new Response with the same attributes except for those given new values""" for x in self.attributes: kwargs.setdefault(x, getattr(self, x)) - cls = kwargs.pop("cls", self.__class__) - return cast(Response, cls(*args, **kwargs)) + if cls is None: + cls = self.__class__ + return cls(*args, **kwargs) def urljoin(self, url: str) -> str: """Join this Response's url with a possible relative url to form an @@ -175,19 +183,19 @@ class Response(object_ref): def follow( self, - url: Union[str, Link], - callback: Optional[Callable] = None, + url: str | Link, + callback: CallbackT | None = None, method: str = "GET", - headers: Union[Mapping[AnyStr, Any], Iterable[Tuple[AnyStr, Any]], None] = None, - body: Optional[Union[bytes, str]] = None, - cookies: Optional[Union[dict, List[dict]]] = None, - meta: Optional[Dict[str, Any]] = None, - encoding: Optional[str] = "utf-8", + headers: Mapping[AnyStr, Any] | Iterable[tuple[AnyStr, Any]] | None = None, + body: bytes | str | None = None, + cookies: CookiesT | None = None, + meta: dict[str, Any] | None = None, + encoding: str | None = "utf-8", priority: int = 0, dont_filter: bool = False, - errback: Optional[Callable] = None, - cb_kwargs: Optional[Dict[str, Any]] = None, - flags: Optional[List[str]] = None, + errback: Callable[[Failure], Any] | None = None, + cb_kwargs: dict[str, Any] | None = None, + flags: list[str] | None = None, ) -> Request: """ Return a :class:`~.Request` instance to follow a link ``url``. @@ -228,20 +236,20 @@ class Response(object_ref): def follow_all( self, - urls: Iterable[Union[str, Link]], - callback: Optional[Callable] = None, + urls: Iterable[str | Link], + callback: CallbackT | None = None, method: str = "GET", - headers: Union[Mapping[AnyStr, Any], Iterable[Tuple[AnyStr, Any]], None] = None, - body: Optional[Union[bytes, str]] = None, - cookies: Optional[Union[dict, List[dict]]] = None, - meta: Optional[Dict[str, Any]] = None, - encoding: Optional[str] = "utf-8", + headers: Mapping[AnyStr, Any] | Iterable[tuple[AnyStr, Any]] | None = None, + body: bytes | str | None = None, + cookies: CookiesT | None = None, + meta: dict[str, Any] | None = None, + encoding: str | None = "utf-8", priority: int = 0, dont_filter: bool = False, - errback: Optional[Callable] = None, - cb_kwargs: Optional[Dict[str, Any]] = None, - flags: Optional[List[str]] = None, - ) -> Generator[Request, None, None]: + errback: Callable[[Failure], Any] | None = None, + cb_kwargs: dict[str, Any] | None = None, + flags: list[str] | None = None, + ) -> Iterable[Request]: """ .. versionadded:: 2.0 diff --git a/scrapy/http/response/text.py b/scrapy/http/response/text.py index 6596d8a5c..c713f6188 100644 --- a/scrapy/http/response/text.py +++ b/scrapy/http/response/text.py @@ -4,25 +4,12 @@ discovering (through HTTP headers) to base Response class. See documentation in docs/topics/request-response.rst """ + from __future__ import annotations import json from contextlib import suppress -from typing import ( - TYPE_CHECKING, - Any, - AnyStr, - Callable, - Dict, - Generator, - Iterable, - List, - Mapping, - Optional, - Tuple, - Union, - cast, -) +from typing import TYPE_CHECKING, Any, AnyStr, cast from urllib.parse import urljoin import parsel @@ -35,15 +22,20 @@ from w3lib.encoding import ( ) from w3lib.html import strip_html5_whitespace -from scrapy.http import Request from scrapy.http.response import Response -from scrapy.link import Link from scrapy.utils.python import memoizemethod_noargs, to_unicode from scrapy.utils.response import get_base_url if TYPE_CHECKING: + from collections.abc import Callable, Iterable, Mapping + + from twisted.python.failure import Failure + + from scrapy.http.request import CallbackT, CookiesT, Request + from scrapy.link import Link from scrapy.selector import Selector, SelectorList + _NONE = object() @@ -51,16 +43,16 @@ class TextResponse(Response): _DEFAULT_ENCODING = "ascii" _cached_decoded_json = _NONE - attributes: Tuple[str, ...] = Response.attributes + ("encoding",) + attributes: tuple[str, ...] = Response.attributes + ("encoding",) def __init__(self, *args: Any, **kwargs: Any): - self._encoding: Optional[str] = kwargs.pop("encoding", None) - self._cached_benc: Optional[str] = None - self._cached_ubody: Optional[str] = None - self._cached_selector: Optional[Selector] = None + self._encoding: str | None = kwargs.pop("encoding", None) + self._cached_benc: str | None = None + self._cached_ubody: str | None = None + self._cached_selector: Selector | None = None super().__init__(*args, **kwargs) - def _set_body(self, body: Union[str, bytes, None]) -> None: + def _set_body(self, body: str | bytes | None) -> None: self._body: bytes = b"" # used by encoding detection if isinstance(body, str): if self._encoding is None: @@ -76,7 +68,7 @@ class TextResponse(Response): def encoding(self) -> str: return self._declared_encoding() or self._body_inferred_encoding() - def _declared_encoding(self) -> Optional[str]: + def _declared_encoding(self) -> str | None: return ( self._encoding or self._bom_encoding() @@ -111,7 +103,7 @@ class TextResponse(Response): return urljoin(get_base_url(self), url) @memoizemethod_noargs - def _headers_encoding(self) -> Optional[str]: + def _headers_encoding(self) -> str | None: content_type = cast(bytes, self.headers.get(b"Content-Type", b"")) return http_content_type_encoding(to_unicode(content_type, encoding="latin-1")) @@ -130,7 +122,7 @@ class TextResponse(Response): self._cached_ubody = ubody return self._cached_benc - def _auto_detect_fun(self, text: bytes) -> Optional[str]: + def _auto_detect_fun(self, text: bytes) -> str | None: for enc in (self._DEFAULT_ENCODING, "utf-8", "cp1252"): try: text.decode(enc) @@ -140,11 +132,11 @@ class TextResponse(Response): return None @memoizemethod_noargs - def _body_declared_encoding(self) -> Optional[str]: + def _body_declared_encoding(self) -> str | None: return html_body_declared_encoding(self.body) @memoizemethod_noargs - def _bom_encoding(self) -> Optional[str]: + def _bom_encoding(self) -> str | None: return read_bom(self.body)[0] @property @@ -158,12 +150,12 @@ class TextResponse(Response): def jmespath(self, query: str, **kwargs: Any) -> SelectorList: from scrapy.selector import SelectorList - if not hasattr(self.selector, "jmespath"): # type: ignore[attr-defined] + 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)) # type: ignore[attr-defined] + return cast(SelectorList, self.selector.jmespath(query, **kwargs)) def xpath(self, query: str, **kwargs: Any) -> SelectorList: from scrapy.selector import SelectorList @@ -177,19 +169,19 @@ class TextResponse(Response): def follow( self, - url: Union[str, Link, parsel.Selector], - callback: Optional[Callable] = None, + url: str | Link | parsel.Selector, + callback: CallbackT | None = None, method: str = "GET", - headers: Union[Mapping[AnyStr, Any], Iterable[Tuple[AnyStr, Any]], None] = None, - body: Optional[Union[bytes, str]] = None, - cookies: Optional[Union[dict, List[dict]]] = None, - meta: Optional[Dict[str, Any]] = None, - encoding: Optional[str] = None, + headers: Mapping[AnyStr, Any] | Iterable[tuple[AnyStr, Any]] | None = None, + body: bytes | str | None = None, + cookies: CookiesT | None = None, + meta: dict[str, Any] | None = None, + encoding: str | None = None, priority: int = 0, dont_filter: bool = False, - errback: Optional[Callable] = None, - cb_kwargs: Optional[Dict[str, Any]] = None, - flags: Optional[List[str]] = None, + errback: Callable[[Failure], Any] | None = None, + cb_kwargs: dict[str, Any] | None = None, + flags: list[str] | None = None, ) -> Request: """ Return a :class:`~.Request` instance to follow a link ``url``. @@ -230,22 +222,22 @@ class TextResponse(Response): def follow_all( self, - urls: Union[Iterable[Union[str, Link]], parsel.SelectorList, None] = None, - callback: Optional[Callable] = None, + urls: Iterable[str | Link] | parsel.SelectorList | None = None, + callback: CallbackT | None = None, method: str = "GET", - headers: Union[Mapping[AnyStr, Any], Iterable[Tuple[AnyStr, Any]], None] = None, - body: Optional[Union[bytes, str]] = None, - cookies: Optional[Union[dict, List[dict]]] = None, - meta: Optional[Dict[str, Any]] = None, - encoding: Optional[str] = None, + headers: Mapping[AnyStr, Any] | Iterable[tuple[AnyStr, Any]] | None = None, + body: bytes | str | None = None, + cookies: CookiesT | None = None, + meta: dict[str, Any] | None = None, + encoding: str | None = None, priority: int = 0, dont_filter: bool = False, - errback: Optional[Callable] = None, - cb_kwargs: Optional[Dict[str, Any]] = None, - flags: Optional[List[str]] = None, - css: Optional[str] = None, - xpath: Optional[str] = None, - ) -> Generator[Request, None, None]: + errback: Callable[[Failure], Any] | None = None, + cb_kwargs: dict[str, Any] | None = None, + flags: list[str] | None = None, + css: str | None = None, + xpath: str | None = None, + ) -> Iterable[Request]: """ A generator that produces :class:`~.Request` instances to follow all links in ``urls``. It accepts the same arguments as the :class:`~.Request`'s @@ -286,7 +278,7 @@ class TextResponse(Response): with suppress(_InvalidSelector): urls.append(_url_from_selector(sel)) return super().follow_all( - urls=cast(Iterable[Union[str, Link]], urls), + urls=cast("Iterable[str | Link]", urls), callback=callback, method=method, headers=headers, diff --git a/scrapy/item.py b/scrapy/item.py index d3eb90b7b..f77002d18 100644 --- a/scrapy/item.py +++ b/scrapy/item.py @@ -4,16 +4,24 @@ Scrapy Item See documentation in docs/topics/item.rst """ +from __future__ import annotations + from abc import ABCMeta from collections.abc import MutableMapping from copy import deepcopy from pprint import pformat -from typing import Dict +from typing import TYPE_CHECKING, Any, NoReturn from scrapy.utils.trackref import object_ref +if TYPE_CHECKING: + from collections.abc import Iterator, KeysView -class Field(dict): + # typing.Self requires Python 3.11 + from typing_extensions import Self + + +class Field(dict[str, Any]): """Container of field metadata""" @@ -23,7 +31,9 @@ class ItemMeta(ABCMeta): .. _metaclass: https://realpython.com/python-metaclasses """ - def __new__(mcs, class_name, bases, attrs): + def __new__( + mcs, class_name: str, bases: tuple[type, ...], attrs: dict[str, Any] + ) -> ItemMeta: classcell = attrs.pop("__classcell__", None) new_bases = tuple(base._class for base in bases if hasattr(base, "_class")) _class = super().__new__(mcs, "x_" + class_name, new_bases, attrs) @@ -44,7 +54,7 @@ class ItemMeta(ABCMeta): return super().__new__(mcs, class_name, bases, new_attrs) -class Item(MutableMapping, object_ref, metaclass=ItemMeta): +class Item(MutableMapping[str, Any], object_ref, metaclass=ItemMeta): """ Base class for scraped items. @@ -67,53 +77,53 @@ class Item(MutableMapping, object_ref, metaclass=ItemMeta): :ref:`tracked ` to debug memory leaks. """ - fields: Dict[str, Field] + fields: dict[str, Field] - def __init__(self, *args, **kwargs): - self._values = {} + def __init__(self, *args: Any, **kwargs: Any): + self._values: dict[str, Any] = {} if args or kwargs: # avoid creating dict for most common case for k, v in dict(*args, **kwargs).items(): self[k] = v - def __getitem__(self, key): + def __getitem__(self, key: str) -> Any: return self._values[key] - def __setitem__(self, key, value): + def __setitem__(self, key: str, value: Any) -> None: if key in self.fields: self._values[key] = value else: raise KeyError(f"{self.__class__.__name__} does not support field: {key}") - def __delitem__(self, key): + def __delitem__(self, key: str) -> None: del self._values[key] - def __getattr__(self, name): + def __getattr__(self, name: str) -> NoReturn: if name in self.fields: raise AttributeError(f"Use item[{name!r}] to get field value") raise AttributeError(name) - def __setattr__(self, name, value): + def __setattr__(self, name: str, value: Any) -> None: if not name.startswith("_"): raise AttributeError(f"Use item[{name!r}] = {value!r} to set field value") super().__setattr__(name, value) - def __len__(self): + def __len__(self) -> int: return len(self._values) - def __iter__(self): + def __iter__(self) -> Iterator[str]: return iter(self._values) __hash__ = object_ref.__hash__ - def keys(self): + def keys(self) -> KeysView[str]: return self._values.keys() - def __repr__(self): + def __repr__(self) -> str: return pformat(dict(self)) - def copy(self): + def copy(self) -> Self: return self.__class__(self) - def deepcopy(self): + def deepcopy(self) -> Self: """Return a :func:`~copy.deepcopy` of this item.""" return deepcopy(self) diff --git a/scrapy/link.py b/scrapy/link.py index 0868ae5ef..4bdbc1823 100644 --- a/scrapy/link.py +++ b/scrapy/link.py @@ -4,6 +4,7 @@ This module defines the Link object used in Link extractors. For actual link extractors implementation see scrapy.linkextractors, or its documentation in: docs/topics/link-extractors.rst """ + from typing import Any diff --git a/scrapy/linkextractors/__init__.py b/scrapy/linkextractors/__init__.py index 3774430a7..1c7e96ae0 100644 --- a/scrapy/linkextractors/__init__.py +++ b/scrapy/linkextractors/__init__.py @@ -5,7 +5,14 @@ This package contains a collection of Link Extractors. For more info see docs/topics/link-extractors.rst """ -import re + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Iterable + from re import Pattern # common file extensions that are not followed if they occur in links IGNORED_EXTENSIONS = [ @@ -109,14 +116,11 @@ IGNORED_EXTENSIONS = [ ] -_re_type = type(re.compile("", 0)) - - -def _matches(url, regexs): +def _matches(url: str, regexs: Iterable[Pattern[str]]) -> bool: return any(r.search(url) for r in regexs) -def _is_valid_url(url): +def _is_valid_url(url: str) -> bool: return url.split("://", 1)[0] in {"http", "https", "file", "ftp"} diff --git a/scrapy/linkextractors/lxmlhtml.py b/scrapy/linkextractors/lxmlhtml.py index 23cbd0116..192f937ce 100644 --- a/scrapy/linkextractors/lxmlhtml.py +++ b/scrapy/linkextractors/lxmlhtml.py @@ -1,29 +1,37 @@ """ Link extractor based on lxml.html """ + +from __future__ import annotations + import logging import operator +import re +from collections.abc import Callable, Iterable from functools import partial +from typing import TYPE_CHECKING, Any, Union, cast from urllib.parse import urljoin, urlparse -from lxml import etree +from lxml import etree # nosec from parsel.csstranslator import HTMLTranslator from w3lib.html import strip_html5_whitespace from w3lib.url import canonicalize_url, safe_url_string from scrapy.link import Link -from scrapy.linkextractors import ( - IGNORED_EXTENSIONS, - _is_valid_url, - _matches, - _re_type, - re, -) +from scrapy.linkextractors import IGNORED_EXTENSIONS, _is_valid_url, _matches from scrapy.utils.misc import arg_to_iter, rel_has_nofollow from scrapy.utils.python import unique as unique_list from scrapy.utils.response import get_base_url from scrapy.utils.url import url_has_any_extension, url_is_from_any_domain +if TYPE_CHECKING: + + from lxml.html import HtmlElement # nosec + + from scrapy import Selector + from scrapy.http import TextResponse + + logger = logging.getLogger(__name__) # from lxml/src/lxml/html/__init__.py @@ -32,41 +40,56 @@ XHTML_NAMESPACE = "http://www.w3.org/1999/xhtml" _collect_string_content = etree.XPath("string()") -def _nons(tag): +def _nons(tag: Any) -> Any: if isinstance(tag, str): if tag[0] == "{" and tag[1 : len(XHTML_NAMESPACE) + 1] == XHTML_NAMESPACE: return tag.split("}")[-1] return tag -def _identity(x): +def _identity(x: Any) -> Any: return x -def _canonicalize_link_url(link): +def _canonicalize_link_url(link: Link) -> str: return canonicalize_url(link.url, keep_fragments=True) class LxmlParserLinkExtractor: def __init__( self, - tag="a", - attr="href", - process=None, - unique=False, - strip=True, - canonicalized=False, + tag: str | Callable[[str], bool] = "a", + attr: str | Callable[[str], bool] = "href", + process: Callable[[Any], Any] | None = None, + unique: bool = False, + strip: bool = True, + canonicalized: bool = False, ): - self.scan_tag = tag if callable(tag) else partial(operator.eq, tag) - self.scan_attr = attr if callable(attr) else partial(operator.eq, attr) - self.process_attr = process if callable(process) else _identity - self.unique = unique - self.strip = strip - self.link_key = ( - operator.attrgetter("url") if canonicalized else _canonicalize_link_url + # mypy doesn't infer types for operator.* and also for partial() + self.scan_tag: Callable[[str], bool] = ( + tag + if callable(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)) + ) + self.process_attr: Callable[[Any], Any] = ( + process if callable(process) else _identity + ) + self.unique: bool = unique + self.strip: bool = strip + self.link_key: Callable[[Link], str] = ( + cast(Callable[[Link], str], operator.attrgetter("url")) + if canonicalized + else _canonicalize_link_url ) - def _iter_links(self, document): + def _iter_links( + self, document: HtmlElement + ) -> Iterable[tuple[HtmlElement, str, str]]: for el in document.iter(etree.Element): if not self.scan_tag(_nons(el.tag)): continue @@ -74,10 +97,16 @@ class LxmlParserLinkExtractor: for attrib in attribs: if not self.scan_attr(attrib): continue - yield (el, attrib, attribs[attrib]) + yield el, attrib, attribs[attrib] - def _extract_links(self, selector, response_url, response_encoding, base_url): - links = [] + def _extract_links( + self, + selector: Selector, + response_url: str, + response_encoding: str, + base_url: str, + ) -> list[Link]: + links: list[Link] = [] # hacky way to get the underlying lxml parsed document for el, attr, attr_val in self._iter_links(selector.root): # pseudo lxml.html.HtmlElement.make_links_absolute(base_url) @@ -107,44 +136,48 @@ class LxmlParserLinkExtractor: links.append(link) return self._deduplicate_if_needed(links) - def extract_links(self, response): + def extract_links(self, response: TextResponse) -> list[Link]: base_url = get_base_url(response) return self._extract_links( response.selector, response.url, response.encoding, base_url ) - def _process_links(self, links): + def _process_links(self, links: list[Link]) -> list[Link]: """Normalize and filter extracted links The subclass should override it if necessary """ return self._deduplicate_if_needed(links) - def _deduplicate_if_needed(self, links): + def _deduplicate_if_needed(self, links: list[Link]) -> list[Link]: if self.unique: return unique_list(links, key=self.link_key) return links +_RegexT = Union[str, re.Pattern[str]] +_RegexOrSeveralT = Union[_RegexT, Iterable[_RegexT]] + + class LxmlLinkExtractor: _csstranslator = HTMLTranslator() def __init__( self, - allow=(), - deny=(), - allow_domains=(), - deny_domains=(), - restrict_xpaths=(), - tags=("a", "area"), - attrs=("href",), - canonicalize=False, - unique=True, - process_value=None, - deny_extensions=None, - restrict_css=(), - strip=True, - restrict_text=None, + allow: _RegexOrSeveralT = (), + deny: _RegexOrSeveralT = (), + allow_domains: str | Iterable[str] = (), + deny_domains: str | Iterable[str] = (), + restrict_xpaths: str | Iterable[str] = (), + tags: str | Iterable[str] = ("a", "area"), + attrs: str | Iterable[str] = ("href",), + canonicalize: bool = False, + unique: bool = True, + process_value: Callable[[Any], Any] | None = None, + deny_extensions: str | Iterable[str] | None = None, + restrict_css: str | Iterable[str] = (), + strip: bool = True, + restrict_text: _RegexOrSeveralT | None = None, ): tags, attrs = set(arg_to_iter(tags)), set(arg_to_iter(attrs)) self.link_extractor = LxmlParserLinkExtractor( @@ -153,33 +186,33 @@ class LxmlLinkExtractor: unique=unique, process=process_value, strip=strip, - canonicalized=canonicalize, + canonicalized=not canonicalize, ) - self.allow_res = [ - x if isinstance(x, _re_type) else re.compile(x) for x in arg_to_iter(allow) - ] - self.deny_res = [ - x if isinstance(x, _re_type) else re.compile(x) for x in arg_to_iter(deny) - ] + self.allow_res: list[re.Pattern[str]] = self._compile_regexes(allow) + self.deny_res: list[re.Pattern[str]] = self._compile_regexes(deny) - self.allow_domains = set(arg_to_iter(allow_domains)) - self.deny_domains = set(arg_to_iter(deny_domains)) + self.allow_domains: set[str] = set(arg_to_iter(allow_domains)) + self.deny_domains: set[str] = set(arg_to_iter(deny_domains)) - self.restrict_xpaths = tuple(arg_to_iter(restrict_xpaths)) + self.restrict_xpaths: tuple[str, ...] = tuple(arg_to_iter(restrict_xpaths)) self.restrict_xpaths += tuple( map(self._csstranslator.css_to_xpath, arg_to_iter(restrict_css)) ) if deny_extensions is None: deny_extensions = IGNORED_EXTENSIONS - self.canonicalize = canonicalize - self.deny_extensions = {"." + e for e in arg_to_iter(deny_extensions)} - self.restrict_text = [ - x if isinstance(x, _re_type) else re.compile(x) - for x in arg_to_iter(restrict_text) + self.canonicalize: bool = canonicalize + self.deny_extensions: set[str] = {"." + e for e in arg_to_iter(deny_extensions)} + self.restrict_text: list[re.Pattern[str]] = self._compile_regexes(restrict_text) + + @staticmethod + def _compile_regexes(value: _RegexOrSeveralT | None) -> list[re.Pattern[str]]: + return [ + x if isinstance(x, re.Pattern) else re.compile(x) + for x in arg_to_iter(value) ] - def _link_allowed(self, link): + def _link_allowed(self, link: Link) -> bool: if not _is_valid_url(link.url): return False if self.allow_res and not _matches(link.url, self.allow_res): @@ -201,7 +234,7 @@ class LxmlLinkExtractor: return False return True - def matches(self, url): + def matches(self, url: str) -> bool: if self.allow_domains and not url_is_from_any_domain(url, self.allow_domains): return False if self.deny_domains and url_is_from_any_domain(url, self.deny_domains): @@ -215,7 +248,7 @@ class LxmlLinkExtractor: denied = (regex.search(url) for regex in self.deny_res) if self.deny_res else [] return any(allowed) and not any(denied) - def _process_links(self, links): + def _process_links(self, links: list[Link]) -> list[Link]: links = [x for x in links if self._link_allowed(x)] if self.canonicalize: for link in links: @@ -223,10 +256,10 @@ class LxmlLinkExtractor: links = self.link_extractor._process_links(links) return links - def _extract_links(self, *args, **kwargs): + def _extract_links(self, *args: Any, **kwargs: Any) -> list[Link]: return self.link_extractor._extract_links(*args, **kwargs) - def extract_links(self, response): + def extract_links(self, response: TextResponse) -> list[Link]: """Returns a list of :class:`~scrapy.link.Link` objects from the specified :class:`response `. @@ -248,5 +281,5 @@ class LxmlLinkExtractor: links = self._extract_links(doc, response.url, response.encoding, base_url) all_links.extend(self._process_links(links)) if self.link_extractor.unique: - return unique_list(all_links) + return unique_list(all_links, key=self.link_extractor.link_key) return all_links diff --git a/scrapy/loader/__init__.py b/scrapy/loader/__init__.py index 1042a3d48..d35720a45 100644 --- a/scrapy/loader/__init__.py +++ b/scrapy/loader/__init__.py @@ -3,11 +3,19 @@ Item Loader See documentation in docs/topics/loaders.rst """ + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + import itemloaders from scrapy.item import Item from scrapy.selector import Selector +if TYPE_CHECKING: + from scrapy.http import TextResponse + class ItemLoader(itemloaders.ItemLoader): """ @@ -81,7 +89,14 @@ class ItemLoader(itemloaders.ItemLoader): default_item_class: type = Item default_selector_class = Selector - def __init__(self, item=None, selector=None, response=None, parent=None, **context): + def __init__( + self, + item: Any = None, + selector: Selector | None = None, + response: TextResponse | None = None, + parent: itemloaders.ItemLoader | None = None, + **context: Any, + ): if selector is None and response is not None: try: selector = self.default_selector_class(response) diff --git a/scrapy/logformatter.py b/scrapy/logformatter.py index d720b2f38..544f4adfe 100644 --- a/scrapy/logformatter.py +++ b/scrapy/logformatter.py @@ -2,12 +2,14 @@ from __future__ import annotations import logging import os -from typing import TYPE_CHECKING, Any, Dict, Optional, Union +from typing import TYPE_CHECKING, Any, TypedDict from twisted.python.failure import Failure -from scrapy import Request, Spider -from scrapy.http import Response +# working around https://github.com/sphinx-doc/sphinx/issues/10400 +from scrapy import Request, Spider # noqa: TC001 +from scrapy.http import Response # noqa: TC001 +from scrapy.utils.python import global_object_name from scrapy.utils.request import referer_str if TYPE_CHECKING: @@ -26,6 +28,12 @@ DOWNLOADERRORMSG_SHORT = "Error downloading %(request)s" DOWNLOADERRORMSG_LONG = "Error downloading %(request)s: %(errmsg)s" +class LogFormatterResult(TypedDict): + level: int + msg: str + args: dict[str, Any] | tuple[Any, ...] + + class LogFormatter: """Class for generating log messages for different actions. @@ -64,7 +72,9 @@ class LogFormatter: } """ - def crawled(self, request: Request, response: Response, spider: Spider) -> dict: + def crawled( + self, request: Request, response: Response, spider: Spider + ) -> LogFormatterResult: """Logs a message when the crawler finds a webpage.""" request_flags = f" {str(request.flags)}" if request.flags else "" response_flags = f" {str(response.flags)}" if response.flags else "" @@ -83,11 +93,13 @@ class LogFormatter: } def scraped( - self, item: Any, response: Union[Response, Failure], spider: Spider - ) -> dict: + self, item: Any, response: Response | Failure | None, spider: Spider + ) -> LogFormatterResult: """Logs a message when an item is scraped by a spider.""" src: Any - if isinstance(response, Failure): + if response is None: + src = f"{global_object_name(spider.__class__)}.start_requests" + elif isinstance(response, Failure): src = response.getErrorMessage() else: src = response @@ -101,8 +113,12 @@ class LogFormatter: } def dropped( - self, item: Any, exception: BaseException, response: Response, spider: Spider - ) -> dict: + self, + item: Any, + exception: BaseException, + response: Response | None, + spider: Spider, + ) -> LogFormatterResult: """Logs a message when an item is dropped while it is passing through the item pipeline.""" return { "level": logging.WARNING, @@ -114,8 +130,12 @@ class LogFormatter: } def item_error( - self, item: Any, exception: BaseException, response: Response, spider: Spider - ) -> dict: + self, + item: Any, + exception: BaseException, + response: Response | None, + spider: Spider, + ) -> LogFormatterResult: """Logs a message when an item causes an error while it is passing through the item pipeline. @@ -133,9 +153,9 @@ class LogFormatter: self, failure: Failure, request: Request, - response: Union[Response, Failure], + response: Response | Failure, spider: Spider, - ) -> dict: + ) -> LogFormatterResult: """Logs an error message from a spider. .. versionadded:: 2.0 @@ -154,14 +174,14 @@ class LogFormatter: failure: Failure, request: Request, spider: Spider, - errmsg: Optional[str] = None, - ) -> dict: + errmsg: str | None = None, + ) -> LogFormatterResult: """Logs a download error message from a spider (typically coming from the engine). .. versionadded:: 2.0 """ - args: Dict[str, Any] = {"request": request} + args: dict[str, Any] = {"request": request} if errmsg: msg = DOWNLOADERRORMSG_LONG args["errmsg"] = errmsg diff --git a/scrapy/mail.py b/scrapy/mail.py index 237327451..1e65b1623 100644 --- a/scrapy/mail.py +++ b/scrapy/mail.py @@ -3,6 +3,9 @@ Mail sending helpers See documentation in docs/topics/email.rst """ + +from __future__ import annotations + import logging from email import encoders as Encoders from email.mime.base import MIMEBase @@ -11,14 +14,29 @@ from email.mime.nonmultipart import MIMENonMultipart from email.mime.text import MIMEText from email.utils import formatdate from io import BytesIO +from typing import IO, TYPE_CHECKING, Any from twisted import version as twisted_version -from twisted.internet import defer, ssl +from twisted.internet import ssl +from twisted.internet.defer import Deferred from twisted.python.versions import Version from scrapy.utils.misc import arg_to_iter from scrapy.utils.python import to_bytes +if TYPE_CHECKING: + from collections.abc import Callable, Sequence + + # imports twisted.internet.reactor + from twisted.mail.smtp import ESMTPSenderFactory + from twisted.python.failure import Failure + + # typing.Self requires Python 3.11 + from typing_extensions import Self + + from scrapy.settings import BaseSettings + + logger = logging.getLogger(__name__) @@ -27,7 +45,7 @@ logger = logging.getLogger(__name__) COMMASPACE = ", " -def _to_bytes_or_none(text): +def _to_bytes_or_none(text: str | bytes | None) -> bytes | None: if text is None: return None return to_bytes(text) @@ -36,26 +54,26 @@ def _to_bytes_or_none(text): class MailSender: def __init__( self, - smtphost="localhost", - mailfrom="scrapy@localhost", - smtpuser=None, - smtppass=None, - smtpport=25, - smtptls=False, - smtpssl=False, - debug=False, + smtphost: str = "localhost", + mailfrom: str = "scrapy@localhost", + smtpuser: str | None = None, + smtppass: str | None = None, + smtpport: int = 25, + smtptls: bool = False, + smtpssl: bool = False, + debug: bool = False, ): - self.smtphost = smtphost - self.smtpport = smtpport - self.smtpuser = _to_bytes_or_none(smtpuser) - self.smtppass = _to_bytes_or_none(smtppass) - self.smtptls = smtptls - self.smtpssl = smtpssl - self.mailfrom = mailfrom - self.debug = debug + self.smtphost: str = smtphost + self.smtpport: int = smtpport + self.smtpuser: bytes | None = _to_bytes_or_none(smtpuser) + self.smtppass: bytes | None = _to_bytes_or_none(smtppass) + self.smtptls: bool = smtptls + self.smtpssl: bool = smtpssl + self.mailfrom: str = mailfrom + self.debug: bool = debug @classmethod - def from_settings(cls, settings): + def from_settings(cls, settings: BaseSettings) -> Self: return cls( smtphost=settings["MAIL_HOST"], mailfrom=settings["MAIL_FROM"], @@ -68,17 +86,18 @@ class MailSender: def send( self, - to, - subject, - body, - cc=None, - attachs=(), - mimetype="text/plain", - charset=None, - _callback=None, - ): + to: str | list[str], + subject: str, + body: str, + cc: str | list[str] | None = None, + attachs: Sequence[tuple[str, str, IO[Any]]] = (), + mimetype: str = "text/plain", + charset: str | None = None, + _callback: Callable[..., None] | None = None, + ) -> Deferred[None] | None: from twisted.internet import reactor + msg: MIMEBase if attachs: msg = MIMEMultipart() else: @@ -125,19 +144,19 @@ class MailSender: "mailattachs": len(attachs), }, ) - return + return None - dfd = self._sendmail(rcpts, msg.as_string().encode(charset or "utf-8")) - dfd.addCallbacks( - callback=self._sent_ok, - errback=self._sent_failed, - callbackArgs=[to, cc, subject, len(attachs)], - errbackArgs=[to, cc, subject, len(attachs)], + dfd: Deferred[Any] = self._sendmail( + rcpts, msg.as_string().encode(charset or "utf-8") ) + dfd.addCallback(self._sent_ok, to, cc, subject, len(attachs)) + dfd.addErrback(self._sent_failed, to, cc, subject, len(attachs)) reactor.addSystemEventTrigger("before", "shutdown", lambda: dfd) return dfd - def _sent_ok(self, result, to, cc, subject, nattachs): + def _sent_ok( + self, result: Any, to: list[str], cc: list[str], subject: str, nattachs: int + ) -> None: logger.info( "Mail sent OK: To=%(mailto)s Cc=%(mailcc)s " 'Subject="%(mailsubject)s" Attachs=%(mailattachs)d', @@ -149,7 +168,14 @@ class MailSender: }, ) - def _sent_failed(self, failure, to, cc, subject, nattachs): + def _sent_failed( + self, + failure: Failure, + to: list[str], + cc: list[str], + subject: str, + nattachs: int, + ) -> Failure: errstr = str(failure.value) logger.error( "Unable to send mail: To=%(mailto)s Cc=%(mailcc)s " @@ -165,13 +191,13 @@ class MailSender: ) return failure - def _sendmail(self, to_addrs, msg): + def _sendmail(self, to_addrs: list[str], msg: bytes) -> Deferred[Any]: from twisted.internet import reactor - msg = BytesIO(msg) - d = defer.Deferred() + msg_io = BytesIO(msg) + d: Deferred[Any] = Deferred() - factory = self._create_sender_factory(to_addrs, msg, d) + factory = self._create_sender_factory(to_addrs, msg_io, d) if self.smtpssl: reactor.connectSSL( @@ -182,10 +208,12 @@ class MailSender: return d - def _create_sender_factory(self, to_addrs, msg, d): + def _create_sender_factory( + self, to_addrs: list[str], msg: IO[bytes], d: Deferred[Any] + ) -> ESMTPSenderFactory: from twisted.mail.smtp import ESMTPSenderFactory - factory_keywords = { + factory_keywords: dict[str, Any] = { "heloFallback": True, "requireAuthentication": False, "requireTransportSecurity": self.smtptls, @@ -202,7 +230,7 @@ class MailSender: to_addrs, msg, d, - **factory_keywords + **factory_keywords, ) factory.noisy = False return factory diff --git a/scrapy/middleware.py b/scrapy/middleware.py index f60c726f9..b6a427895 100644 --- a/scrapy/middleware.py +++ b/scrapy/middleware.py @@ -3,37 +3,33 @@ from __future__ import annotations import logging import pprint from collections import defaultdict, deque -from typing import ( - TYPE_CHECKING, - Any, - Callable, - Deque, - Dict, - Iterable, - List, - Optional, - Tuple, - Union, - cast, -) +from typing import TYPE_CHECKING, Any, TypeVar, cast -from twisted.internet.defer import Deferred - -from scrapy import Spider from scrapy.exceptions import NotConfigured -from scrapy.settings import Settings from scrapy.utils.defer import process_chain, process_parallel from scrapy.utils.misc import build_from_crawler, build_from_settings, load_object if TYPE_CHECKING: - # typing.Self requires Python 3.11 - from typing_extensions import Self + 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 scrapy import Spider from scrapy.crawler import Crawler + from scrapy.settings import Settings + + _P = ParamSpec("_P") logger = logging.getLogger(__name__) +_T = TypeVar("_T") +_T2 = TypeVar("_T2") + class MiddlewareManager: """Base class for implementing middleware managers""" @@ -44,20 +40,18 @@ class MiddlewareManager: self.middlewares = 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[Union[None, Callable, Tuple[Callable, Callable]]] - ] = defaultdict(deque) + self.methods: dict[str, deque[Callable | tuple[Callable, Callable] | None]] = ( + defaultdict(deque) + ) for mw in middlewares: self._add_middleware(mw) @classmethod - def _get_mwlist_from_settings(cls, settings: Settings) -> List[Any]: + def _get_mwlist_from_settings(cls, settings: Settings) -> list[Any]: raise NotImplementedError @classmethod - def from_settings( - cls, settings: Settings, crawler: Optional[Crawler] = None - ) -> Self: + def from_settings(cls, settings: Settings, crawler: Crawler | None = None) -> Self: mwlist = cls._get_mwlist_from_settings(settings) middlewares = [] enabled = [] @@ -98,16 +92,22 @@ class MiddlewareManager: if hasattr(mw, "close_spider"): self.methods["close_spider"].appendleft(mw.close_spider) - def _process_parallel(self, methodname: str, obj: Any, *args: Any) -> Deferred: - methods = cast(Iterable[Callable], self.methods[methodname]) + 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 _process_chain(self, methodname: str, obj: Any, *args: Any) -> Deferred: - methods = cast(Iterable[Callable], self.methods[methodname]) + def _process_chain(self, methodname: str, obj: _T, *args: Any) -> Deferred[_T]: + methods = cast( + "Iterable[Callable[Concatenate[_T, _P], _T]]", self.methods[methodname] + ) return process_chain(methods, obj, *args) - def open_spider(self, spider: Spider) -> Deferred: + def open_spider(self, spider: Spider) -> Deferred[list[None]]: return self._process_parallel("open_spider", spider) - def close_spider(self, spider: Spider) -> Deferred: + def close_spider(self, spider: Spider) -> Deferred[list[None]]: return self._process_parallel("close_spider", spider) diff --git a/scrapy/pipelines/__init__.py b/scrapy/pipelines/__init__.py index c97d71fb6..01f8bd2c8 100644 --- a/scrapy/pipelines/__init__.py +++ b/scrapy/pipelines/__init__.py @@ -3,21 +3,27 @@ Item pipeline See documentation in docs/item-pipeline.rst """ -from typing import Any, List -from twisted.internet.defer import Deferred +from __future__ import annotations + +from typing import TYPE_CHECKING, Any -from scrapy import Spider from scrapy.middleware import MiddlewareManager from scrapy.utils.conf import build_component_list from scrapy.utils.defer import deferred_f_from_coro_f +if TYPE_CHECKING: + from twisted.internet.defer import Deferred + + from scrapy import Spider + from scrapy.settings import Settings + class ItemPipelineManager(MiddlewareManager): component_name = "item pipeline" @classmethod - def _get_mwlist_from_settings(cls, settings) -> List[Any]: + def _get_mwlist_from_settings(cls, settings: Settings) -> list[Any]: return build_component_list(settings.getwithbase("ITEM_PIPELINES")) def _add_middleware(self, pipe: Any) -> None: @@ -27,5 +33,5 @@ class ItemPipelineManager(MiddlewareManager): deferred_f_from_coro_f(pipe.process_item) ) - def process_item(self, item: Any, spider: Spider) -> Deferred: + def process_item(self, item: Any, spider: Spider) -> Deferred[Any]: return self._process_chain("process_item", item, spider) diff --git a/scrapy/pipelines/files.py b/scrapy/pipelines/files.py index 73064ad10..4a8639c22 100644 --- a/scrapy/pipelines/files.py +++ b/scrapy/pipelines/files.py @@ -3,6 +3,9 @@ Files Pipeline See documentation in topics/media-pipeline.rst """ + +from __future__ import annotations + import base64 import functools import hashlib @@ -13,55 +16,114 @@ from collections import defaultdict from contextlib import suppress from ftplib import FTP from io import BytesIO -from os import PathLike from pathlib import Path -from typing import DefaultDict, Optional, Set, Union +from typing import IO, TYPE_CHECKING, Any, NoReturn, Protocol, TypedDict, cast from urllib.parse import urlparse from itemadapter import ItemAdapter -from twisted.internet import defer, threads +from twisted.internet.defer import Deferred, maybeDeferred +from twisted.internet.threads import deferToThread from scrapy.exceptions import IgnoreRequest, NotConfigured -from scrapy.http import Request +from scrapy.http import Request, Response from scrapy.http.request import NO_CALLBACK -from scrapy.pipelines.media import MediaPipeline +from scrapy.pipelines.media import FileInfo, FileInfoOrError, MediaPipeline from scrapy.settings import Settings from scrapy.utils.boto import is_botocore_available from scrapy.utils.datatypes import CaseInsensitiveDict from scrapy.utils.ftp import ftp_store_file from scrapy.utils.log import failure_to_exc_info -from scrapy.utils.misc import md5sum from scrapy.utils.python import to_bytes from scrapy.utils.request import referer_str +if TYPE_CHECKING: + from collections.abc import Callable + from os import PathLike + + from twisted.python.failure import Failure + + # typing.Self requires Python 3.11 + from typing_extensions import Self + + from scrapy import Spider + + logger = logging.getLogger(__name__) -def _to_string(path: Union[str, PathLike]) -> str: +def _to_string(path: str | PathLike[str]) -> str: return str(path) # convert a Path object to string +def _md5sum(file: IO[bytes]) -> str: + """Calculate the md5 checksum of a file-like object without reading its + whole content in memory. + + >>> from io import BytesIO + >>> _md5sum(BytesIO(b'file content to hash')) + '784406af91dd5a54fbb9c84c2236595a' + """ + m = hashlib.md5() # nosec + while True: + d = file.read(8096) + if not d: + break + m.update(d) + return m.hexdigest() + + class FileException(Exception): """General media error exception""" +class StatInfo(TypedDict, total=False): + checksum: str + last_modified: float + + +class FilesStoreProtocol(Protocol): + def __init__(self, basedir: str): ... + + def persist_file( + self, + path: str, + buf: BytesIO, + info: MediaPipeline.SpiderInfo, + meta: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + ) -> Deferred[Any] | None: ... + + def stat_file( + self, path: str, info: MediaPipeline.SpiderInfo + ) -> StatInfo | Deferred[StatInfo]: ... + + class FSFilesStore: - def __init__(self, basedir: Union[str, PathLike]): + def __init__(self, basedir: str | PathLike[str]): basedir = _to_string(basedir) if "://" in basedir: basedir = basedir.split("://", 1)[1] - self.basedir = basedir + self.basedir: str = basedir self._mkdir(Path(self.basedir)) - self.created_directories: DefaultDict[str, Set[str]] = defaultdict(set) + self.created_directories: defaultdict[MediaPipeline.SpiderInfo, set[str]] = ( + defaultdict(set) + ) def persist_file( - self, path: Union[str, PathLike], buf, info, meta=None, headers=None - ): + self, + path: str | PathLike[str], + buf: BytesIO, + info: MediaPipeline.SpiderInfo, + meta: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + ) -> None: absolute_path = self._get_filesystem_path(path) self._mkdir(absolute_path.parent, info) absolute_path.write_bytes(buf.getvalue()) - def stat_file(self, path: Union[str, PathLike], info): + def stat_file( + self, path: str | PathLike[str], info: MediaPipeline.SpiderInfo + ) -> StatInfo: absolute_path = self._get_filesystem_path(path) try: last_modified = absolute_path.stat().st_mtime @@ -69,16 +131,18 @@ class FSFilesStore: return {} with absolute_path.open("rb") as f: - checksum = md5sum(f) + checksum = _md5sum(f) return {"last_modified": last_modified, "checksum": checksum} - def _get_filesystem_path(self, path: Union[str, PathLike]) -> Path: + def _get_filesystem_path(self, path: str | PathLike[str]) -> Path: path_comps = _to_string(path).split("/") return Path(self.basedir, *path_comps) - def _mkdir(self, dirname: Path, domain: Optional[str] = None): - seen = self.created_directories[domain] if domain else set() + def _mkdir( + self, dirname: Path, domain: MediaPipeline.SpiderInfo | None = None + ) -> None: + seen: set[str] = self.created_directories[domain] if domain else set() if str(dirname) not in seen: if not dirname.exists(): dirname.mkdir(parents=True) @@ -99,7 +163,7 @@ class S3FilesStore: "Cache-Control": "max-age=172800", } - def __init__(self, uri): + def __init__(self, uri: str): if not is_botocore_available(): raise NotConfigured("missing botocore library") import botocore.session @@ -119,8 +183,10 @@ class S3FilesStore: raise ValueError(f"Incorrect URI scheme in {uri}, expected 's3'") self.bucket, self.prefix = uri[5:].split("/", 1) - def stat_file(self, path, info): - def _onsuccess(boto_key): + def stat_file( + self, path: str, info: MediaPipeline.SpiderInfo + ) -> Deferred[StatInfo]: + def _onsuccess(boto_key: dict[str, Any]) -> StatInfo: checksum = boto_key["ETag"].strip('"') last_modified = boto_key["LastModified"] modified_stamp = time.mktime(last_modified.timetuple()) @@ -128,21 +194,31 @@ class S3FilesStore: return self._get_boto_key(path).addCallback(_onsuccess) - def _get_boto_key(self, path): + def _get_boto_key(self, path: str) -> Deferred[dict[str, Any]]: key_name = f"{self.prefix}{path}" - return threads.deferToThread( - self.s3_client.head_object, Bucket=self.bucket, Key=key_name + return cast( + "Deferred[dict[str, Any]]", + deferToThread( + self.s3_client.head_object, Bucket=self.bucket, Key=key_name # type: ignore[attr-defined] + ), ) - def persist_file(self, path, buf, info, meta=None, headers=None): + def persist_file( + self, + path: str, + buf: BytesIO, + info: MediaPipeline.SpiderInfo, + meta: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + ) -> Deferred[Any]: """Upload file to S3 storage""" key_name = f"{self.prefix}{path}" buf.seek(0) extra = self._headers_to_botocore_kwargs(self.HEADERS) if headers: extra.update(self._headers_to_botocore_kwargs(headers)) - return threads.deferToThread( - self.s3_client.put_object, + return deferToThread( + self.s3_client.put_object, # type: ignore[attr-defined] Bucket=self.bucket, Key=key_name, Body=buf, @@ -151,7 +227,7 @@ class S3FilesStore: **extra, ) - def _headers_to_botocore_kwargs(self, headers): + def _headers_to_botocore_kwargs(self, headers: dict[str, Any]) -> dict[str, Any]: """Convert headers to botocore keyword arguments.""" # This is required while we need to support both boto and botocore. mapping = CaseInsensitiveDict( @@ -183,7 +259,7 @@ class S3FilesStore: "X-Amz-Website-Redirect-Location": "WebsiteRedirectLocation", } ) - extra = {} + extra: dict[str, Any] = {} for key, value in headers.items(): try: kwarg = mapping[key] @@ -203,13 +279,13 @@ class GCSFilesStore: # Overridden from settings.FILES_STORE_GCS_ACL in FilesPipeline.from_settings. POLICY = None - def __init__(self, uri): + def __init__(self, uri: str): from google.cloud import storage client = storage.Client(project=self.GCS_PROJECT_ID) bucket, prefix = uri[5:].split("/", 1) self.bucket = client.bucket(bucket) - self.prefix = prefix + self.prefix: str = prefix permissions = self.bucket.test_iam_permissions( ["storage.objects.get", "storage.objects.create"] ) @@ -225,8 +301,10 @@ class GCSFilesStore: {"bucket": bucket}, ) - def stat_file(self, path, info): - def _onsuccess(blob): + def stat_file( + self, path: str, info: MediaPipeline.SpiderInfo + ) -> Deferred[StatInfo]: + def _onsuccess(blob) -> StatInfo: if blob: checksum = base64.b64decode(blob.md5_hash).hex() last_modified = time.mktime(blob.updated.timetuple()) @@ -234,24 +312,32 @@ class GCSFilesStore: return {} blob_path = self._get_blob_path(path) - return threads.deferToThread(self.bucket.get_blob, blob_path).addCallback( - _onsuccess + return cast( + Deferred[StatInfo], + deferToThread(self.bucket.get_blob, blob_path).addCallback(_onsuccess), ) - def _get_content_type(self, headers): + def _get_content_type(self, headers: dict[str, str] | None) -> str: if headers and "Content-Type" in headers: return headers["Content-Type"] return "application/octet-stream" - def _get_blob_path(self, path): + def _get_blob_path(self, path: str) -> str: return self.prefix + path - def persist_file(self, path, buf, info, meta=None, headers=None): + def persist_file( + self, + path: str, + buf: BytesIO, + info: MediaPipeline.SpiderInfo, + meta: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + ) -> Deferred[Any]: blob_path = self._get_blob_path(path) blob = self.bucket.blob(blob_path) blob.cache_control = self.CACHE_CONTROL blob.metadata = {k: str(v) for k, v in (meta or {}).items()} - return threads.deferToThread( + return deferToThread( blob.upload_from_string, data=buf.getvalue(), content_type=self._get_content_type(headers), @@ -260,24 +346,35 @@ class GCSFilesStore: class FTPFilesStore: - FTP_USERNAME = None - FTP_PASSWORD = None - USE_ACTIVE_MODE = None + FTP_USERNAME: str | None = None + FTP_PASSWORD: str | None = None + USE_ACTIVE_MODE: bool | None = None - def __init__(self, uri): + def __init__(self, uri: str): if not uri.startswith("ftp://"): raise ValueError(f"Incorrect URI scheme in {uri}, expected 'ftp'") u = urlparse(uri) - self.port = u.port - self.host = u.hostname + assert u.port + assert u.hostname + self.port: int = u.port + self.host: str = u.hostname self.port = int(u.port or 21) - self.username = u.username or self.FTP_USERNAME - self.password = u.password or self.FTP_PASSWORD - self.basedir = u.path.rstrip("/") + assert self.FTP_USERNAME + assert self.FTP_PASSWORD + self.username: str = u.username or self.FTP_USERNAME + self.password: str = u.password or self.FTP_PASSWORD + self.basedir: str = u.path.rstrip("/") - def persist_file(self, path, buf, info, meta=None, headers=None): + def persist_file( + self, + path: str, + buf: BytesIO, + info: MediaPipeline.SpiderInfo, + meta: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + ) -> Deferred[Any]: path = f"{self.basedir}/{path}" - return threads.deferToThread( + return deferToThread( ftp_store_file, path=path, file=buf, @@ -288,8 +385,10 @@ class FTPFilesStore: use_active_mode=self.USE_ACTIVE_MODE, ) - def stat_file(self, path, info): - def _stat_file(path): + def stat_file( + self, path: str, info: MediaPipeline.SpiderInfo + ) -> Deferred[StatInfo]: + def _stat_file(path: str) -> StatInfo: try: ftp = FTP() ftp.connect(self.host, self.port) @@ -298,14 +397,14 @@ class FTPFilesStore: ftp.set_pasv(False) file_path = f"{self.basedir}/{path}" last_modified = float(ftp.voidcmd(f"MDTM {file_path}")[4:].strip()) - m = hashlib.md5() + m = hashlib.md5() # nosec ftp.retrbinary(f"RETR {file_path}", m.update) return {"last_modified": last_modified, "checksum": m.hexdigest()} # The file doesn't exist except Exception: return {} - return threads.deferToThread(_stat_file, path) + return cast("Deferred[StatInfo]", deferToThread(_stat_file, path)) class FilesPipeline(MediaPipeline): @@ -327,20 +426,23 @@ class FilesPipeline(MediaPipeline): """ - MEDIA_NAME = "file" - EXPIRES = 90 - STORE_SCHEMES = { + MEDIA_NAME: str = "file" + EXPIRES: int = 90 + STORE_SCHEMES: dict[str, type[FilesStoreProtocol]] = { "": FSFilesStore, "file": FSFilesStore, "s3": S3FilesStore, "gs": GCSFilesStore, "ftp": FTPFilesStore, } - DEFAULT_FILES_URLS_FIELD = "file_urls" - DEFAULT_FILES_RESULT_FIELD = "files" + DEFAULT_FILES_URLS_FIELD: str = "file_urls" + DEFAULT_FILES_RESULT_FIELD: str = "files" def __init__( - self, store_uri: Union[str, PathLike], download_func=None, settings=None + self, + store_uri: str | PathLike[str], + download_func: Callable[[Request, Spider], Response] | None = None, + settings: Settings | dict[str, Any] | None = None, ): store_uri = _to_string(store_uri) if not store_uri: @@ -349,27 +451,27 @@ class FilesPipeline(MediaPipeline): if isinstance(settings, dict) or settings is None: settings = Settings(settings) cls_name = "FilesPipeline" - self.store = self._get_store(store_uri) + self.store: FilesStoreProtocol = self._get_store(store_uri) resolve = functools.partial( self._key_for_pipe, base_class_name=cls_name, settings=settings ) - self.expires = settings.getint(resolve("FILES_EXPIRES"), self.EXPIRES) + self.expires: int = settings.getint(resolve("FILES_EXPIRES"), self.EXPIRES) if not hasattr(self, "FILES_URLS_FIELD"): self.FILES_URLS_FIELD = self.DEFAULT_FILES_URLS_FIELD if not hasattr(self, "FILES_RESULT_FIELD"): self.FILES_RESULT_FIELD = self.DEFAULT_FILES_RESULT_FIELD - self.files_urls_field = settings.get( + self.files_urls_field: str = settings.get( resolve("FILES_URLS_FIELD"), self.FILES_URLS_FIELD ) - self.files_result_field = settings.get( + self.files_result_field: str = settings.get( resolve("FILES_RESULT_FIELD"), self.FILES_RESULT_FIELD ) super().__init__(download_func=download_func, settings=settings) @classmethod - def from_settings(cls, settings): - s3store = cls.STORE_SCHEMES["s3"] + def from_settings(cls, settings: Settings) -> Self: + 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"] @@ -379,11 +481,15 @@ class FilesPipeline(MediaPipeline): s3store.AWS_VERIFY = settings["AWS_VERIFY"] s3store.POLICY = settings["FILES_STORE_S3_ACL"] - gcs_store = cls.STORE_SCHEMES["gs"] + gcs_store: type[GCSFilesStore] = cast( + 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 = cls.STORE_SCHEMES["ftp"] + ftp_store: type[FTPFilesStore] = cast( + type[FTPFilesStore], cls.STORE_SCHEMES["ftp"] + ) ftp_store.FTP_USERNAME = settings["FTP_USER"] ftp_store.FTP_PASSWORD = settings["FTP_PASSWORD"] ftp_store.USE_ACTIVE_MODE = settings.getbool("FEED_STORAGE_FTP_ACTIVE") @@ -391,7 +497,7 @@ class FilesPipeline(MediaPipeline): store_uri = settings["FILES_STORE"] return cls(store_uri, settings=settings) - def _get_store(self, uri: str): + def _get_store(self, uri: str) -> FilesStoreProtocol: if Path(uri).is_absolute(): # to support win32 paths like: C:\\some\dir scheme = "file" else: @@ -399,19 +505,21 @@ class FilesPipeline(MediaPipeline): store_cls = self.STORE_SCHEMES[scheme] return store_cls(uri) - def media_to_download(self, request, info, *, item=None): - def _onsuccess(result): + def media_to_download( + self, request: Request, info: MediaPipeline.SpiderInfo, *, item: Any = None + ) -> Deferred[FileInfo | None]: + def _onsuccess(result: StatInfo) -> FileInfo | None: if not result: - return # returning None force download + return None # returning None force download last_modified = result.get("last_modified", None) if not last_modified: - return # returning None force download + return None # returning None force download age_seconds = time.time() - last_modified age_days = age_seconds / 60 / 60 / 24 if age_days > self.expires: - return # returning None force download + return None # returning None force download referer = referer_str(request) logger.debug( @@ -431,18 +539,22 @@ class FilesPipeline(MediaPipeline): } path = self.file_path(request, info=info, item=item) - dfd = defer.maybeDeferred(self.store.stat_file, path, info) - dfd.addCallbacks(_onsuccess, lambda _: None) - dfd.addErrback( + # maybeDeferred() overloads don't seem to support a Union[_T, Deferred[_T]] return type + dfd: Deferred[StatInfo] = maybeDeferred(self.store.stat_file, path, info) # type: ignore[call-overload] + dfd2: Deferred[FileInfo | None] = dfd.addCallback(_onsuccess) + dfd2.addErrback(lambda _: None) + dfd2.addErrback( lambda f: logger.error( self.__class__.__name__ + ".store.stat_file", exc_info=failure_to_exc_info(f), extra={"spider": info.spider}, ) ) - return dfd + return dfd2 - def media_failed(self, failure, request, info): + def media_failed( + self, failure: Failure, request: Request, info: MediaPipeline.SpiderInfo + ) -> NoReturn: if not isinstance(failure.value, IgnoreRequest): referer = referer_str(request) logger.warning( @@ -459,7 +571,14 @@ class FilesPipeline(MediaPipeline): raise FileException - def media_downloaded(self, response, request, info, *, item=None): + def media_downloaded( + self, + response: Response, + request: Request, + info: MediaPipeline.SpiderInfo, + *, + item: Any = None, + ) -> FileInfo: referer = referer_str(request) if response.status != 200: @@ -518,30 +637,49 @@ class FilesPipeline(MediaPipeline): "status": status, } - def inc_stats(self, spider, 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) # Overridable Interface - def get_media_requests(self, item, info): + def get_media_requests( + self, item: Any, info: MediaPipeline.SpiderInfo + ) -> list[Request]: urls = ItemAdapter(item).get(self.files_urls_field, []) return [Request(u, callback=NO_CALLBACK) for u in urls] - def file_downloaded(self, response, request, info, *, item=None): + def file_downloaded( + self, + response: Response, + request: Request, + info: MediaPipeline.SpiderInfo, + *, + item: Any = None, + ) -> str: path = self.file_path(request, response=response, info=info, item=item) buf = BytesIO(response.body) - checksum = md5sum(buf) + checksum = _md5sum(buf) buf.seek(0) self.store.persist_file(path, buf, info) return checksum - def item_completed(self, results, item, info): + def item_completed( + self, results: list[FileInfoOrError], item: Any, info: MediaPipeline.SpiderInfo + ) -> Any: with suppress(KeyError): ItemAdapter(item)[self.files_result_field] = [x for ok, x in results if ok] return item - def file_path(self, request, response=None, info=None, *, item=None): - media_guid = hashlib.sha1(to_bytes(request.url)).hexdigest() + def file_path( + self, + request: Request, + response: Response | None = None, + info: MediaPipeline.SpiderInfo | None = None, + *, + item: Any = None, + ) -> str: + media_guid = hashlib.sha1(to_bytes(request.url)).hexdigest() # nosec media_ext = Path(request.url).suffix # Handles empty and wild extensions by trying to guess the # mime type then extension or default to empty string otherwise @@ -549,5 +687,5 @@ class FilesPipeline(MediaPipeline): media_ext = "" media_type = mimetypes.guess_type(request.url)[0] if media_type: - media_ext = 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 02c4b1361..bbba7d1e1 100644 --- a/scrapy/pipelines/images.py +++ b/scrapy/pipelines/images.py @@ -3,31 +3,49 @@ Images Pipeline See documentation in topics/media-pipeline.rst """ + +from __future__ import annotations + import functools import hashlib import warnings from contextlib import suppress from io import BytesIO -from os import PathLike -from typing import Dict, Tuple, Union +from typing import TYPE_CHECKING, Any, cast from itemadapter import ItemAdapter from scrapy.exceptions import DropItem, NotConfigured, ScrapyDeprecationWarning -from scrapy.http import Request +from scrapy.http import Request, Response from scrapy.http.request import NO_CALLBACK -from scrapy.pipelines.files import FileException, FilesPipeline - -# TODO: from scrapy.pipelines.media import MediaPipeline +from scrapy.pipelines.files import ( + FileException, + FilesPipeline, + FTPFilesStore, + GCSFilesStore, + S3FilesStore, + _md5sum, +) from scrapy.settings import Settings -from scrapy.utils.misc import md5sum from scrapy.utils.python import get_func_args, to_bytes +if TYPE_CHECKING: + from collections.abc import Callable, Iterable + from os import PathLike + + from PIL import Image + + # typing.Self requires Python 3.11 + from typing_extensions import Self + + from scrapy import Spider + from scrapy.pipelines.media import FileInfoOrError, MediaPipeline + class NoimagesDrop(DropItem): """Product with no images exception""" - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any): warnings.warn( "The NoimagesDrop class is deprecated", category=ScrapyDeprecationWarning, @@ -43,19 +61,22 @@ class ImageException(FileException): class ImagesPipeline(FilesPipeline): """Abstract pipeline that implement the image thumbnail generation logic""" - MEDIA_NAME = "image" + MEDIA_NAME: str = "image" # Uppercase attributes kept for backward compatibility with code that subclasses # ImagesPipeline. They may be overridden by settings. - MIN_WIDTH = 0 - MIN_HEIGHT = 0 - EXPIRES = 90 - THUMBS: Dict[str, Tuple[int, int]] = {} + MIN_WIDTH: int = 0 + MIN_HEIGHT: int = 0 + EXPIRES: int = 90 + THUMBS: dict[str, tuple[int, int]] = {} DEFAULT_IMAGES_URLS_FIELD = "image_urls" DEFAULT_IMAGES_RESULT_FIELD = "images" def __init__( - self, store_uri: Union[str, PathLike], download_func=None, settings=None + self, + store_uri: str | PathLike[str], + download_func: Callable[[Request, Spider], Response] | None = None, + settings: Settings | dict[str, Any] | None = None, ): try: from PIL import Image @@ -76,28 +97,34 @@ class ImagesPipeline(FilesPipeline): base_class_name="ImagesPipeline", settings=settings, ) - self.expires = settings.getint(resolve("IMAGES_EXPIRES"), self.EXPIRES) + self.expires: int = settings.getint(resolve("IMAGES_EXPIRES"), self.EXPIRES) if not hasattr(self, "IMAGES_RESULT_FIELD"): - self.IMAGES_RESULT_FIELD = self.DEFAULT_IMAGES_RESULT_FIELD + self.IMAGES_RESULT_FIELD: str = self.DEFAULT_IMAGES_RESULT_FIELD if not hasattr(self, "IMAGES_URLS_FIELD"): - self.IMAGES_URLS_FIELD = self.DEFAULT_IMAGES_URLS_FIELD + self.IMAGES_URLS_FIELD: str = self.DEFAULT_IMAGES_URLS_FIELD - self.images_urls_field = settings.get( + self.images_urls_field: str = settings.get( resolve("IMAGES_URLS_FIELD"), self.IMAGES_URLS_FIELD ) - self.images_result_field = settings.get( + self.images_result_field: str = settings.get( resolve("IMAGES_RESULT_FIELD"), self.IMAGES_RESULT_FIELD ) - self.min_width = settings.getint(resolve("IMAGES_MIN_WIDTH"), self.MIN_WIDTH) - self.min_height = settings.getint(resolve("IMAGES_MIN_HEIGHT"), self.MIN_HEIGHT) - self.thumbs = settings.get(resolve("IMAGES_THUMBS"), self.THUMBS) + self.min_width: int = settings.getint( + resolve("IMAGES_MIN_WIDTH"), self.MIN_WIDTH + ) + self.min_height: int = settings.getint( + resolve("IMAGES_MIN_HEIGHT"), self.MIN_HEIGHT + ) + self.thumbs: dict[str, tuple[int, int]] = settings.get( + resolve("IMAGES_THUMBS"), self.THUMBS + ) - self._deprecated_convert_image = None + self._deprecated_convert_image: bool | None = None @classmethod - def from_settings(cls, settings): - s3store = cls.STORE_SCHEMES["s3"] + def from_settings(cls, settings: Settings) -> Self: + 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"] @@ -107,11 +134,15 @@ class ImagesPipeline(FilesPipeline): s3store.AWS_VERIFY = settings["AWS_VERIFY"] s3store.POLICY = settings["IMAGES_STORE_S3_ACL"] - gcs_store = cls.STORE_SCHEMES["gs"] + gcs_store: type[GCSFilesStore] = cast( + type[GCSFilesStore], cls.STORE_SCHEMES["gs"] + ) gcs_store.GCS_PROJECT_ID = settings["GCS_PROJECT_ID"] gcs_store.POLICY = settings["IMAGES_STORE_GCS_ACL"] or None - ftp_store = cls.STORE_SCHEMES["ftp"] + ftp_store: type[FTPFilesStore] = cast( + type[FTPFilesStore], cls.STORE_SCHEMES["ftp"] + ) ftp_store.FTP_USERNAME = settings["FTP_USER"] ftp_store.FTP_PASSWORD = settings["FTP_PASSWORD"] ftp_store.USE_ACTIVE_MODE = settings.getbool("FEED_STORAGE_FTP_ACTIVE") @@ -119,15 +150,29 @@ class ImagesPipeline(FilesPipeline): store_uri = settings["IMAGES_STORE"] return cls(store_uri, settings=settings) - def file_downloaded(self, response, request, info, *, item=None): + def file_downloaded( + self, + response: Response, + request: Request, + info: MediaPipeline.SpiderInfo, + *, + item: Any = None, + ) -> str: return self.image_downloaded(response, request, info, item=item) - def image_downloaded(self, response, request, info, *, item=None): - checksum = None + def image_downloaded( + self, + response: Response, + request: Request, + info: MediaPipeline.SpiderInfo, + *, + item: Any = None, + ) -> str: + checksum: str | None = None for path, image, buf in self.get_images(response, request, info, item=item): if checksum is None: buf.seek(0) - checksum = md5sum(buf) + checksum = _md5sum(buf) width, height = image.size self.store.persist_file( path, @@ -136,9 +181,17 @@ class ImagesPipeline(FilesPipeline): meta={"width": width, "height": height}, headers={"Content-Type": "image/jpeg"}, ) + assert checksum is not None return checksum - def get_images(self, response, request, info, *, item=None): + def get_images( + self, + response: Response, + request: Request, + info: MediaPipeline.SpiderInfo, + *, + item: Any = None, + ) -> 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)) @@ -179,7 +232,12 @@ class ImagesPipeline(FilesPipeline): thumb_image, thumb_buf = self.convert_image(image, size, buf) yield thumb_path, thumb_image, thumb_buf - def convert_image(self, image, size=None, response_body=None): + def convert_image( + self, + image: Image.Image, + size: tuple[int, int] | None = None, + response_body: BytesIO | None = None, + ) -> tuple[Image.Image, BytesIO]: if response_body is None: warnings.warn( f"{self.__class__.__name__}.convert_image() method called in a deprecated way, " @@ -208,7 +266,7 @@ class ImagesPipeline(FilesPipeline): # when updating the minimum requirements for Pillow. resampling_filter = self._Image.Resampling.LANCZOS except AttributeError: - resampling_filter = self._Image.ANTIALIAS + resampling_filter = self._Image.ANTIALIAS # type: ignore[attr-defined] image.thumbnail(size, resampling_filter) elif response_body is not None and image.format == "JPEG": return image, response_body @@ -217,19 +275,38 @@ class ImagesPipeline(FilesPipeline): image.save(buf, "JPEG") return image, buf - def get_media_requests(self, item, info): + def get_media_requests( + self, item: Any, info: MediaPipeline.SpiderInfo + ) -> list[Request]: urls = ItemAdapter(item).get(self.images_urls_field, []) return [Request(u, callback=NO_CALLBACK) for u in urls] - def item_completed(self, results, item, info): + def item_completed( + self, results: list[FileInfoOrError], item: Any, info: MediaPipeline.SpiderInfo + ) -> Any: with suppress(KeyError): ItemAdapter(item)[self.images_result_field] = [x for ok, x in results if ok] return item - def file_path(self, request, response=None, info=None, *, item=None): - image_guid = hashlib.sha1(to_bytes(request.url)).hexdigest() + def file_path( + self, + request: Request, + response: Response | None = None, + info: MediaPipeline.SpiderInfo | None = None, + *, + item: Any = None, + ) -> str: + image_guid = hashlib.sha1(to_bytes(request.url)).hexdigest() # nosec return f"full/{image_guid}.jpg" - def thumb_path(self, request, thumb_id, response=None, info=None, *, item=None): - thumb_guid = hashlib.sha1(to_bytes(request.url)).hexdigest() + def thumb_path( + self, + request: Request, + thumb_id: str, + response: Response | None = None, + info: MediaPipeline.SpiderInfo | None = None, + *, + item: Any = None, + ) -> str: + thumb_guid = hashlib.sha1(to_bytes(request.url)).hexdigest() # nosec return f"thumbs/{thumb_id}/{thumb_guid}.jpg" diff --git a/scrapy/pipelines/media.py b/scrapy/pipelines/media.py index fc156ab41..61eddffa7 100644 --- a/scrapy/pipelines/media.py +++ b/scrapy/pipelines/media.py @@ -1,60 +1,101 @@ +from __future__ import annotations + import functools import logging +from abc import ABC, abstractmethod from collections import defaultdict +from typing import ( + TYPE_CHECKING, + Any, + Literal, + NoReturn, + TypedDict, + TypeVar, + Union, + cast, +) from twisted.internet.defer import Deferred, DeferredList from twisted.python.failure import Failure -from scrapy.http.request import NO_CALLBACK +from scrapy.http.request import NO_CALLBACK, Request from scrapy.settings import Settings from scrapy.utils.datatypes import SequenceExclude from scrapy.utils.defer import defer_result, mustbe_deferred from scrapy.utils.log import failure_to_exc_info from scrapy.utils.misc import arg_to_iter +if TYPE_CHECKING: + from collections.abc import Callable + + # typing.Self requires Python 3.11 + from typing_extensions import Self + + from scrapy import Spider + from scrapy.crawler import Crawler + from scrapy.http import Response + from scrapy.utils.request import RequestFingerprinter + + +_T = TypeVar("_T") + + +class FileInfo(TypedDict): + url: str + path: str + checksum: str | None + status: str + + +FileInfoOrError = Union[tuple[Literal[True], FileInfo], tuple[Literal[False], Failure]] + + logger = logging.getLogger(__name__) -def _DUMMY_CALLBACK(response): - return response +class MediaPipeline(ABC): + crawler: Crawler + _fingerprinter: RequestFingerprinter - -class MediaPipeline: - LOG_FAILED_RESULTS = True + LOG_FAILED_RESULTS: bool = True class SpiderInfo: - def __init__(self, spider): - self.spider = spider - self.downloading = set() - self.downloaded = {} - self.waiting = defaultdict(list) + def __init__(self, spider: Spider): + self.spider: Spider = spider + self.downloading: set[bytes] = set() + self.downloaded: dict[bytes, FileInfo | Failure] = {} + self.waiting: defaultdict[bytes, list[Deferred[FileInfo]]] = defaultdict( + list + ) - def __init__(self, download_func=None, settings=None): + def __init__( + self, + download_func: Callable[[Request, Spider], Response] | None = None, + settings: Settings | dict[str, Any] | None = None, + ): self.download_func = download_func - self._expects_item = {} if isinstance(settings, dict) or settings is None: settings = Settings(settings) resolve = functools.partial( self._key_for_pipe, base_class_name="MediaPipeline", settings=settings ) - self.allow_redirects = settings.getbool(resolve("MEDIA_ALLOW_REDIRECTS"), False) + self.allow_redirects: bool = settings.getbool( + resolve("MEDIA_ALLOW_REDIRECTS"), False + ) self._handle_statuses(self.allow_redirects) - def _handle_statuses(self, allow_redirects): + def _handle_statuses(self, allow_redirects: bool) -> None: self.handle_httpstatus_list = None if allow_redirects: self.handle_httpstatus_list = SequenceExclude(range(300, 400)) - def _key_for_pipe(self, key, base_class_name=None, settings=None): - """ - >>> MediaPipeline()._key_for_pipe("IMAGES") - 'IMAGES' - >>> class MyPipe(MediaPipeline): - ... pass - >>> MyPipe()._key_for_pipe("IMAGES", base_class_name="MediaPipeline") - 'MYPIPE_IMAGES' - """ + def _key_for_pipe( + self, + key: str, + base_class_name: str | None = None, + settings: Settings | None = None, + ) -> str: class_name = self.__class__.__name__ formatted_key = f"{class_name.upper()}_{key}" if ( @@ -67,41 +108,50 @@ class MediaPipeline: return formatted_key @classmethod - def from_crawler(cls, crawler): + def from_crawler(cls, crawler: Crawler) -> Self: + pipe: Self try: - pipe = cls.from_settings(crawler.settings) + pipe = cls.from_settings(crawler.settings) # type: ignore[attr-defined] except AttributeError: pipe = cls() pipe.crawler = crawler + assert crawler.request_fingerprinter pipe._fingerprinter = crawler.request_fingerprinter return pipe - def open_spider(self, spider): + def open_spider(self, spider: Spider) -> None: self.spiderinfo = self.SpiderInfo(spider) - def process_item(self, item, spider): + def process_item( + self, item: Any, spider: Spider + ) -> Deferred[list[FileInfoOrError]]: info = self.spiderinfo requests = arg_to_iter(self.get_media_requests(item, info)) dlist = [self._process_request(r, info, item) for r in requests] - dfd = DeferredList(dlist, consumeErrors=True) + dfd = cast( + "Deferred[list[FileInfoOrError]]", DeferredList(dlist, consumeErrors=True) + ) return dfd.addCallback(self.item_completed, item, info) - def _process_request(self, request, info, item): + def _process_request( + self, request: Request, info: SpiderInfo, item: Any + ) -> Deferred[FileInfo]: fp = self._fingerprinter.fingerprint(request) - if not request.callback or request.callback is NO_CALLBACK: - cb = _DUMMY_CALLBACK - else: - cb = request.callback eb = request.errback request.callback = NO_CALLBACK request.errback = None # Return cached result if request was already seen if fp in info.downloaded: - return defer_result(info.downloaded[fp]).addCallbacks(cb, eb) + d = defer_result(info.downloaded[fp]) + if eb: + d.addErrback(eb) + return d # Otherwise, wait for result - wad = Deferred().addCallbacks(cb, eb) + wad: Deferred[FileInfo] = Deferred() + if eb: + wad.addErrback(eb) info.waiting[fp].append(wad) # Check if request is downloading right now to avoid doing it twice @@ -110,53 +160,53 @@ class MediaPipeline: # Download request checking media_to_download hook output first info.downloading.add(fp) - dfd = mustbe_deferred(self.media_to_download, request, info, item=item) - dfd.addCallback(self._check_media_to_download, request, info, item=item) - dfd.addErrback(self._log_exception) - dfd.addBoth(self._cache_result_and_execute_waiters, fp, info) - return dfd.addBoth(lambda _: wad) # it must return wad at last + 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): + def _log_exception(self, result: Failure) -> Failure: logger.exception(result) return result - def _modify_media_request(self, request): + def _modify_media_request(self, request: Request) -> None: if self.handle_httpstatus_list: request.meta["handle_httpstatus_list"] = self.handle_httpstatus_list else: request.meta["handle_httpstatus_all"] = True - def _check_media_to_download(self, result, request, info, item): + 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) - dfd.addCallbacks( - callback=self.media_downloaded, - callbackArgs=(request, info), - callbackKeywords={"item": item}, - errback=self.media_failed, - errbackArgs=(request, info), - ) else: self._modify_media_request(request) + assert self.crawler.engine dfd = self.crawler.engine.download(request) - dfd.addCallbacks( - callback=self.media_downloaded, - callbackArgs=(request, info), - callbackKeywords={"item": item}, - errback=self.media_failed, - errbackArgs=(request, info), - ) - return dfd + dfd2: Deferred[FileInfo] = dfd.addCallback( + self.media_downloaded, request, info, item=item + ) + dfd2.addErrback(self.media_failed, request, info) + return dfd2 - def _cache_result_and_execute_waiters(self, result, fp, info): + def _cache_result_and_execute_waiters( + self, result: FileInfo | Failure, fp: bytes, info: SpiderInfo + ) -> None: if isinstance(result, Failure): # minimize cached information for failure result.cleanFailure() result.frames = [] - result.stack = None + result.stack = [] # This code fixes a memory leak by avoiding to keep references to # the Request and Response objects on the Media Pipeline cache. @@ -181,7 +231,7 @@ class MediaPipeline: # Exception Chaining (https://www.python.org/dev/peps/pep-3134/). context = getattr(result.value, "__context__", None) if isinstance(context, StopIteration): - setattr(result.value, "__context__", None) + result.value.__context__ = None info.downloading.remove(fp) info.downloaded[fp] = result # cache result @@ -189,27 +239,45 @@ class MediaPipeline: defer_result(result).chainDeferred(wad) # Overridable Interface - def media_to_download(self, request, info, *, item=None): + @abstractmethod + def media_to_download( + self, request: Request, info: SpiderInfo, *, item: Any = None + ) -> Deferred[FileInfo | None]: """Check request before starting download""" - pass + raise NotImplementedError() - def get_media_requests(self, item, info): + @abstractmethod + def get_media_requests(self, item: Any, info: SpiderInfo) -> list[Request]: """Returns the media requests to download""" - pass + raise NotImplementedError() - def media_downloaded(self, response, request, info, *, item=None): + @abstractmethod + def media_downloaded( + self, + response: Response, + request: Request, + info: SpiderInfo, + *, + item: Any = None, + ) -> FileInfo: """Handler for success downloads""" - return response + raise NotImplementedError() - def media_failed(self, failure, request, info): + @abstractmethod + def media_failed( + self, failure: Failure, request: Request, info: SpiderInfo + ) -> NoReturn: """Handler for failed downloads""" - return failure + raise NotImplementedError() - def item_completed(self, results, item, info): + def item_completed( + self, results: list[FileInfoOrError], item: Any, info: SpiderInfo + ) -> Any: """Called per item when all media requests has been processed""" if self.LOG_FAILED_RESULTS: for ok, value in results: if not ok: + assert isinstance(value, Failure) logger.error( "%(class)s found errors processing %(item)s", {"class": self.__class__.__name__, "item": item}, @@ -218,6 +286,14 @@ class MediaPipeline: ) return item - def file_path(self, request, response=None, info=None, *, item=None): + @abstractmethod + def file_path( + self, + request: Request, + response: Response | None = None, + info: SpiderInfo | None = None, + *, + item: Any = None, + ) -> str: """Returns the path where downloaded media should be stored""" - pass + raise NotImplementedError() diff --git a/scrapy/pqueues.py b/scrapy/pqueues.py index b62d2fe58..28e2073a2 100644 --- a/scrapy/pqueues.py +++ b/scrapy/pqueues.py @@ -1,12 +1,25 @@ +from __future__ import annotations + import hashlib import logging +from typing import TYPE_CHECKING, Protocol, cast +from scrapy import Request +from scrapy.core.downloader import Downloader from scrapy.utils.misc import build_from_crawler +if TYPE_CHECKING: + from collections.abc import Iterable + + # typing.Self requires Python 3.11 + from typing_extensions import Self + + from scrapy.crawler import Crawler + logger = logging.getLogger(__name__) -def _path_safe(text): +def _path_safe(text: str) -> str: """ Return a filesystem-safe version of a string ``text`` @@ -20,10 +33,22 @@ def _path_safe(text): pathable_slot = "".join([c if c.isalnum() or c in "-._" else "_" for c in text]) # as we replace some letters we can get collision for different slots # add we add unique part - unique_slot = hashlib.md5(text.encode("utf8")).hexdigest() + unique_slot = hashlib.md5(text.encode("utf8")).hexdigest() # nosec return "-".join([pathable_slot, unique_slot]) +class QueueProtocol(Protocol): + """Protocol for downstream queues of ``ScrapyPriorityQueue``.""" + + def push(self, request: Request) -> None: ... + + def pop(self) -> Request | None: ... + + def close(self) -> None: ... + + def __len__(self) -> int: ... + + class ScrapyPriorityQueue: """A priority queue implemented using multiple internal queues (typically, FIFO queues). It uses one internal queue for each priority value. The internal @@ -51,18 +76,30 @@ class ScrapyPriorityQueue: """ @classmethod - def from_crawler(cls, crawler, downstream_queue_cls, key, startprios=()): + def from_crawler( + cls, + crawler: Crawler, + downstream_queue_cls: type[QueueProtocol], + key: str, + startprios: Iterable[int] = (), + ) -> Self: return cls(crawler, downstream_queue_cls, key, startprios) - def __init__(self, crawler, downstream_queue_cls, key, startprios=()): - self.crawler = crawler - self.downstream_queue_cls = downstream_queue_cls - self.key = key - self.queues = {} - self.curprio = None + def __init__( + self, + crawler: Crawler, + downstream_queue_cls: type[QueueProtocol], + key: str, + startprios: Iterable[int] = (), + ): + self.crawler: Crawler = crawler + self.downstream_queue_cls: type[QueueProtocol] = downstream_queue_cls + self.key: str = key + self.queues: dict[int, QueueProtocol] = {} + self.curprio: int | None = None self.init_prios(startprios) - def init_prios(self, startprios): + def init_prios(self, startprios: Iterable[int]) -> None: if not startprios: return @@ -71,17 +108,17 @@ class ScrapyPriorityQueue: self.curprio = min(startprios) - def qfactory(self, key): + def qfactory(self, key: int) -> QueueProtocol: return build_from_crawler( self.downstream_queue_cls, self.crawler, self.key + "/" + str(key), ) - def priority(self, request): + def priority(self, request: Request) -> int: return -request.priority - def push(self, request): + def push(self, request: Request) -> None: priority = self.priority(request) if priority not in self.queues: self.queues[priority] = self.qfactory(priority) @@ -90,9 +127,9 @@ class ScrapyPriorityQueue: if self.curprio is None or priority < self.curprio: self.curprio = priority - def pop(self): + def pop(self) -> Request | None: if self.curprio is None: - return + return None q = self.queues[self.curprio] m = q.pop() if not q: @@ -102,7 +139,7 @@ class ScrapyPriorityQueue: self.curprio = min(prios) if prios else None return m - def peek(self): + def peek(self) -> Request | None: """Returns the next object to be returned by :meth:`pop`, but without removing it from the queue. @@ -112,30 +149,32 @@ class ScrapyPriorityQueue: if self.curprio is None: return None queue = self.queues[self.curprio] - return queue.peek() + # Protocols can't declare optional members + return cast(Request, queue.peek()) # type: ignore[attr-defined] - def close(self): - active = [] + def close(self) -> list[int]: + active: list[int] = [] for p, q in self.queues.items(): active.append(p) q.close() return active - def __len__(self): + def __len__(self) -> int: return sum(len(x) for x in self.queues.values()) if self.queues else 0 class DownloaderInterface: - def __init__(self, crawler): - self.downloader = crawler.engine.downloader + def __init__(self, crawler: Crawler): + assert crawler.engine + self.downloader: Downloader = crawler.engine.downloader - def stats(self, possible_slots): + def stats(self, possible_slots: Iterable[str]) -> list[tuple[int, str]]: return [(self._active_downloads(slot), slot) for slot in possible_slots] - def get_slot_key(self, request): - return self.downloader._get_slot_key(request, None) + def get_slot_key(self, request: Request) -> str: + return self.downloader.get_slot_key(request) - def _active_downloads(self, slot): + def _active_downloads(self, slot: str) -> int: """Return a number of requests in a Downloader for a given slot""" if slot not in self.downloader.slots: return 0 @@ -149,10 +188,22 @@ class DownloaderAwarePriorityQueue: """ @classmethod - def from_crawler(cls, crawler, downstream_queue_cls, key, startprios=()): + def from_crawler( + cls, + crawler: Crawler, + downstream_queue_cls: type[QueueProtocol], + key: str, + startprios: dict[str, Iterable[int]] | None = None, + ) -> Self: return cls(crawler, downstream_queue_cls, key, startprios) - def __init__(self, crawler, downstream_queue_cls, key, slot_startprios=()): + def __init__( + self, + crawler: Crawler, + downstream_queue_cls: type[QueueProtocol], + key: str, + slot_startprios: dict[str, Iterable[int]] | None = None, + ): if crawler.settings.getint("CONCURRENT_REQUESTS_PER_IP") != 0: raise ValueError( f'"{self.__class__}" does not support CONCURRENT_REQUESTS_PER_IP' @@ -169,16 +220,18 @@ class DownloaderAwarePriorityQueue: "queue class can be resumed." ) - self._downloader_interface = DownloaderInterface(crawler) - self.downstream_queue_cls = downstream_queue_cls - self.key = key - self.crawler = crawler + self._downloader_interface: DownloaderInterface = DownloaderInterface(crawler) + self.downstream_queue_cls: type[QueueProtocol] = downstream_queue_cls + self.key: str = key + self.crawler: Crawler = crawler - self.pqueues = {} # slot -> priority queue + self.pqueues: dict[str, ScrapyPriorityQueue] = {} # slot -> priority queue for slot, startprios in (slot_startprios or {}).items(): self.pqueues[slot] = self.pqfactory(slot, startprios) - def pqfactory(self, slot, startprios=()): + def pqfactory( + self, slot: str, startprios: Iterable[int] = () + ) -> ScrapyPriorityQueue: return ScrapyPriorityQueue( self.crawler, self.downstream_queue_cls, @@ -186,11 +239,11 @@ class DownloaderAwarePriorityQueue: startprios, ) - def pop(self): + def pop(self) -> Request | None: stats = self._downloader_interface.stats(self.pqueues) if not stats: - return + return None slot = min(stats)[1] queue = self.pqueues[slot] @@ -199,14 +252,14 @@ class DownloaderAwarePriorityQueue: del self.pqueues[slot] return request - def push(self, request): + def push(self, request: Request) -> None: slot = self._downloader_interface.get_slot_key(request) if slot not in self.pqueues: self.pqueues[slot] = self.pqfactory(slot) queue = self.pqueues[slot] queue.push(request) - def peek(self): + def peek(self) -> Request | None: """Returns the next object to be returned by :meth:`pop`, but without removing it from the queue. @@ -220,13 +273,13 @@ class DownloaderAwarePriorityQueue: queue = self.pqueues[slot] return queue.peek() - def close(self): + def close(self) -> dict[str, list[int]]: active = {slot: queue.close() for slot, queue in self.pqueues.items()} self.pqueues.clear() return active - def __len__(self): + def __len__(self) -> int: return sum(len(x) for x in self.pqueues.values()) if self.pqueues else 0 - def __contains__(self, slot): + def __contains__(self, slot: str) -> bool: return slot in self.pqueues diff --git a/scrapy/resolver.py b/scrapy/resolver.py index e2e8beff4..99a6cc5f6 100644 --- a/scrapy/resolver.py +++ b/scrapy/resolver.py @@ -1,8 +1,11 @@ -from typing import Any +from __future__ import annotations + +from typing import TYPE_CHECKING, Any from twisted.internet import defer -from twisted.internet.base import ThreadedResolver +from twisted.internet.base import ReactorBase, ThreadedResolver from twisted.internet.interfaces import ( + IAddress, IHostnameResolver, IHostResolution, IResolutionReceiver, @@ -12,6 +15,16 @@ from zope.interface.declarations import implementer, provider from scrapy.utils.datatypes import LocalCache +if TYPE_CHECKING: + from collections.abc import Sequence + + from twisted.internet.defer import Deferred + + # typing.Self requires Python 3.11 + from typing_extensions import Self + + from scrapy.crawler import Crawler + # TODO: cache misses dnscache: LocalCache[str, Any] = LocalCache(10000) @@ -22,65 +35,66 @@ class CachingThreadedResolver(ThreadedResolver): Default caching resolver. IPv4 only, supports setting a timeout value for DNS requests. """ - def __init__(self, reactor, cache_size, timeout): + def __init__(self, reactor: ReactorBase, cache_size: int, timeout: float): super().__init__(reactor) dnscache.limit = cache_size self.timeout = timeout @classmethod - def from_crawler(cls, crawler, reactor): + def from_crawler(cls, crawler: Crawler, reactor: ReactorBase) -> Self: if crawler.settings.getbool("DNSCACHE_ENABLED"): cache_size = crawler.settings.getint("DNSCACHE_SIZE") else: cache_size = 0 return cls(reactor, cache_size, crawler.settings.getfloat("DNS_TIMEOUT")) - def install_on_reactor(self): + def install_on_reactor(self) -> None: self.reactor.installResolver(self) - def getHostByName(self, name: str, timeout=None): + def getHostByName(self, name: str, timeout: Sequence[int] = ()) -> Deferred[str]: if name in dnscache: return defer.succeed(dnscache[name]) # in Twisted<=16.6, getHostByName() is always called with # a default timeout of 60s (actually passed as (1, 3, 11, 45) tuple), # so the input argument above is simply overridden # to enforce Scrapy's DNS_TIMEOUT setting's value - timeout = (self.timeout,) + # The timeout arg is typed as Sequence[int] but supports floats. + timeout = (self.timeout,) # type: ignore[assignment] d = super().getHostByName(name, timeout) if dnscache.limit: d.addCallback(self._cache_result, name) return d - def _cache_result(self, result, name): + def _cache_result(self, result: Any, name: str) -> Any: dnscache[name] = result return result @implementer(IHostResolution) class HostResolution: - def __init__(self, name): - self.name = name + def __init__(self, name: str): + self.name: str = name - def cancel(self): + def cancel(self) -> None: raise NotImplementedError() @provider(IResolutionReceiver) class _CachingResolutionReceiver: - def __init__(self, resolutionReceiver, hostName): - self.resolutionReceiver = resolutionReceiver - self.hostName = hostName - self.addresses = [] + def __init__(self, resolutionReceiver: IResolutionReceiver, hostName: str): + self.resolutionReceiver: IResolutionReceiver = resolutionReceiver + self.hostName: str = hostName + self.addresses: list[IAddress] = [] - def resolutionBegan(self, resolution): + def resolutionBegan(self, resolution: IHostResolution) -> None: self.resolutionReceiver.resolutionBegan(resolution) self.resolution = resolution - def addressResolved(self, address): + def addressResolved(self, address: IAddress) -> None: self.resolutionReceiver.addressResolved(address) self.addresses.append(address) - def resolutionComplete(self): + def resolutionComplete(self) -> None: self.resolutionReceiver.resolutionComplete() if self.addresses: dnscache[self.hostName] = self.addresses @@ -93,30 +107,30 @@ class CachingHostnameResolver: does not support setting a timeout value for DNS requests. """ - def __init__(self, reactor, cache_size): - self.reactor = reactor - self.original_resolver = reactor.nameResolver + def __init__(self, reactor: ReactorBase, cache_size: int): + self.reactor: ReactorBase = reactor + self.original_resolver: IHostnameResolver = reactor.nameResolver dnscache.limit = cache_size @classmethod - def from_crawler(cls, crawler, reactor): + def from_crawler(cls, crawler: Crawler, reactor: ReactorBase) -> Self: if crawler.settings.getbool("DNSCACHE_ENABLED"): cache_size = crawler.settings.getint("DNSCACHE_SIZE") else: cache_size = 0 return cls(reactor, cache_size) - def install_on_reactor(self): + def install_on_reactor(self) -> None: self.reactor.installNameResolver(self) def resolveHostName( self, - resolutionReceiver, + resolutionReceiver: IResolutionReceiver, hostName: str, - portNumber=0, - addressTypes=None, - transportSemantics="TCP", - ): + portNumber: int = 0, + addressTypes: Sequence[type[IAddress]] | None = None, + transportSemantics: str = "TCP", + ) -> IHostResolution: try: addresses = dnscache[hostName] except KeyError: diff --git a/scrapy/responsetypes.py b/scrapy/responsetypes.py index 0d127d851..3f6f030a5 100644 --- a/scrapy/responsetypes.py +++ b/scrapy/responsetypes.py @@ -2,15 +2,21 @@ This module implements a class which returns the appropriate Response class based on different criteria. """ + +from __future__ import annotations + from io import StringIO from mimetypes import MimeTypes from pkgutil import get_data -from typing import Dict, Mapping, Optional, Type, Union +from typing import TYPE_CHECKING from scrapy.http import Response from scrapy.utils.misc import load_object from scrapy.utils.python import binary_is_text, to_bytes, to_unicode +if TYPE_CHECKING: + from collections.abc import Mapping + class ResponseTypes: CLASSES = { @@ -31,7 +37,7 @@ class ResponseTypes: } def __init__(self) -> None: - self.classes: Dict[str, Type[Response]] = {} + self.classes: dict[str, type[Response]] = {} self.mimetypes: MimeTypes = MimeTypes() mimedata = get_data("scrapy", "mime.types") if not mimedata: @@ -42,7 +48,7 @@ class ResponseTypes: for mimetype, cls in self.CLASSES.items(): self.classes[mimetype] = load_object(cls) - def from_mimetype(self, mimetype: str) -> Type[Response]: + def from_mimetype(self, mimetype: str) -> type[Response]: """Return the most appropriate Response class for the given mimetype""" if mimetype is None: return Response @@ -52,8 +58,8 @@ class ResponseTypes: return self.classes.get(basetype, Response) def from_content_type( - self, content_type: Union[str, bytes], content_encoding: Optional[bytes] = None - ) -> Type[Response]: + self, content_type: str | bytes, content_encoding: bytes | None = None + ) -> type[Response]: """Return the most appropriate Response class from an HTTP Content-Type header""" if content_encoding: @@ -64,8 +70,8 @@ class ResponseTypes: return self.from_mimetype(mimetype) def from_content_disposition( - self, content_disposition: Union[str, bytes] - ) -> Type[Response]: + self, content_disposition: str | bytes + ) -> type[Response]: try: filename = ( to_unicode(content_disposition, encoding="latin-1", errors="replace") @@ -77,7 +83,7 @@ class ResponseTypes: except IndexError: return Response - def from_headers(self, headers: Mapping[bytes, bytes]) -> Type[Response]: + def from_headers(self, headers: Mapping[bytes, bytes]) -> type[Response]: """Return the most appropriate Response class by looking at the HTTP headers""" cls = Response @@ -90,14 +96,14 @@ class ResponseTypes: cls = self.from_content_disposition(headers[b"Content-Disposition"]) return cls - def from_filename(self, filename: str) -> Type[Response]: + def from_filename(self, filename: str) -> type[Response]: """Return the most appropriate Response class from a file name""" mimetype, encoding = self.mimetypes.guess_type(filename) if mimetype and not encoding: return self.from_mimetype(mimetype) return Response - def from_body(self, body: bytes) -> Type[Response]: + def from_body(self, body: bytes) -> type[Response]: """Try to guess the appropriate response based on the body content. This method is a bit magic and could be improved in the future, but it's not meant to be used except for special cases where response types @@ -117,11 +123,11 @@ class ResponseTypes: def from_args( self, - headers: Optional[Mapping[bytes, bytes]] = None, - url: Optional[str] = None, - filename: Optional[str] = None, - body: Optional[bytes] = None, - ) -> Type[Response]: + headers: Mapping[bytes, bytes] | None = None, + url: str | None = None, + filename: str | None = None, + body: bytes | None = None, + ) -> type[Response]: """Guess the most appropriate Response class based on the given arguments.""" cls = Response diff --git a/scrapy/robotstxt.py b/scrapy/robotstxt.py index 6ea2bfd97..f0a6e7467 100644 --- a/scrapy/robotstxt.py +++ b/scrapy/robotstxt.py @@ -3,27 +3,29 @@ from __future__ import annotations import logging import sys from abc import ABCMeta, abstractmethod -from typing import TYPE_CHECKING, Union -from warnings import warn +from typing import TYPE_CHECKING -from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.utils.python import to_unicode if TYPE_CHECKING: # typing.Self requires Python 3.11 from typing_extensions import Self + from scrapy import Spider from scrapy.crawler import Crawler + logger = logging.getLogger(__name__) -def decode_robotstxt(robotstxt_body, spider, to_native_str_type=False): +def decode_robotstxt( + robotstxt_body: bytes, spider: Spider | None, to_native_str_type: bool = False +) -> str: try: if to_native_str_type: - robotstxt_body = to_unicode(robotstxt_body) + body_decoded = to_unicode(robotstxt_body) else: - robotstxt_body = robotstxt_body.decode("utf-8") + body_decoded = robotstxt_body.decode("utf-8", 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. @@ -33,8 +35,8 @@ def decode_robotstxt(robotstxt_body, spider, to_native_str_type=False): exc_info=sys.exc_info(), extra={"spider": spider}, ) - robotstxt_body = "" - return robotstxt_body + body_decoded = "" + return body_decoded class RobotParser(metaclass=ABCMeta): @@ -53,7 +55,7 @@ class RobotParser(metaclass=ABCMeta): pass @abstractmethod - def allowed(self, url: Union[str, bytes], user_agent: Union[str, bytes]) -> bool: + def allowed(self, url: str | bytes, user_agent: str | bytes) -> bool: """Return ``True`` if ``user_agent`` is allowed to crawl ``url``, otherwise return ``False``. :param url: Absolute URL @@ -66,82 +68,62 @@ class RobotParser(metaclass=ABCMeta): class PythonRobotParser(RobotParser): - def __init__(self, robotstxt_body, spider): + def __init__(self, robotstxt_body: bytes, spider: Spider | None): from urllib.robotparser import RobotFileParser - self.spider = spider - robotstxt_body = decode_robotstxt( - robotstxt_body, spider, to_native_str_type=True - ) - self.rp = RobotFileParser() - self.rp.parse(robotstxt_body.splitlines()) + self.spider: Spider | None = spider + body_decoded = decode_robotstxt(robotstxt_body, spider, to_native_str_type=True) + self.rp: RobotFileParser = RobotFileParser() + self.rp.parse(body_decoded.splitlines()) @classmethod - def from_crawler(cls, crawler, robotstxt_body): + def from_crawler(cls, crawler: Crawler, robotstxt_body: bytes) -> Self: spider = None if not crawler else crawler.spider o = cls(robotstxt_body, spider) return o - def allowed(self, url, user_agent): + def allowed(self, url: str | bytes, user_agent: str | bytes) -> bool: user_agent = to_unicode(user_agent) url = to_unicode(url) return self.rp.can_fetch(user_agent, url) -class ReppyRobotParser(RobotParser): - def __init__(self, robotstxt_body, spider): - warn("ReppyRobotParser is deprecated.", ScrapyDeprecationWarning, stacklevel=2) - from reppy.robots import Robots - - self.spider = spider - self.rp = Robots.parse("", robotstxt_body) - - @classmethod - def from_crawler(cls, crawler, robotstxt_body): - spider = None if not crawler else crawler.spider - o = cls(robotstxt_body, spider) - return o - - def allowed(self, url, user_agent): - return self.rp.allowed(url, user_agent) - - class RerpRobotParser(RobotParser): - def __init__(self, robotstxt_body, spider): + def __init__(self, robotstxt_body: bytes, spider: Spider | None): from robotexclusionrulesparser import RobotExclusionRulesParser - self.spider = spider - self.rp = RobotExclusionRulesParser() - robotstxt_body = decode_robotstxt(robotstxt_body, spider) - self.rp.parse(robotstxt_body) + self.spider: Spider | None = spider + self.rp: RobotExclusionRulesParser = RobotExclusionRulesParser() + body_decoded = decode_robotstxt(robotstxt_body, spider) + self.rp.parse(body_decoded) @classmethod - def from_crawler(cls, crawler, robotstxt_body): + def from_crawler(cls, crawler: Crawler, robotstxt_body: bytes) -> Self: spider = None if not crawler else crawler.spider o = cls(robotstxt_body, spider) return o - def allowed(self, url, user_agent): + def allowed(self, url: str | bytes, user_agent: str | bytes) -> bool: user_agent = to_unicode(user_agent) url = to_unicode(url) return self.rp.is_allowed(user_agent, url) class ProtegoRobotParser(RobotParser): - def __init__(self, robotstxt_body, spider): + def __init__(self, robotstxt_body: bytes, spider: Spider | None): from protego import Protego - self.spider = spider - robotstxt_body = decode_robotstxt(robotstxt_body, spider) - self.rp = Protego.parse(robotstxt_body) + self.spider: Spider | None = spider + body_decoded = decode_robotstxt(robotstxt_body, spider) + self.rp = Protego.parse(body_decoded) @classmethod - def from_crawler(cls, crawler, robotstxt_body): + def from_crawler(cls, crawler: Crawler, robotstxt_body: bytes) -> Self: spider = None if not crawler else crawler.spider o = cls(robotstxt_body, spider) return o - def allowed(self, url, user_agent): + def allowed(self, url: str | bytes, user_agent: str | bytes) -> bool: user_agent = to_unicode(user_agent) url = to_unicode(url) return self.rp.can_fetch(url, user_agent) diff --git a/scrapy/selector/unified.py b/scrapy/selector/unified.py index 863fb6032..db9014b41 100644 --- a/scrapy/selector/unified.py +++ b/scrapy/selector/unified.py @@ -1,12 +1,16 @@ """ XPath selectors based on lxml """ -from typing import Any, Optional, Type, Union + +from __future__ import annotations + +from typing import Any from parsel import Selector as _ParselSelector from scrapy.http import HtmlResponse, TextResponse, XmlResponse from scrapy.utils.python import to_bytes +from scrapy.utils.response import get_base_url from scrapy.utils.trackref import object_ref __all__ = ["Selector", "SelectorList"] @@ -14,14 +18,14 @@ __all__ = ["Selector", "SelectorList"] _NOT_SET = object() -def _st(response: Optional[TextResponse], st: Optional[str]) -> str: +def _st(response: TextResponse | None, st: str | None) -> str: if st is None: return "xml" if isinstance(response, XmlResponse) else "html" return st -def _response_from_text(text: Union[str, bytes], st: Optional[str]) -> TextResponse: - rt: Type[TextResponse] = XmlResponse if st == "xml" else HtmlResponse +def _response_from_text(text: str | bytes, st: str | None) -> TextResponse: + rt: type[TextResponse] = XmlResponse if st == "xml" else HtmlResponse return rt(url="about:blank", encoding="utf-8", body=to_bytes(text, "utf-8")) @@ -45,7 +49,7 @@ class Selector(_ParselSelector, object_ref): ``response`` isn't available. Using ``text`` and ``response`` together is undefined behavior. - ``type`` defines the selector type, it can be ``"html"``, ``"xml"`` + ``type`` defines the selector type, it can be ``"html"``, ``"xml"``, ``"json"`` or ``None`` (default). If ``type`` is ``None``, the selector automatically chooses the best type @@ -57,6 +61,7 @@ class Selector(_ParselSelector, object_ref): * ``"html"`` for :class:`~scrapy.http.HtmlResponse` type * ``"xml"`` for :class:`~scrapy.http.XmlResponse` type + * ``"json"`` for :class:`~scrapy.http.TextResponse` type * ``"html"`` for anything else Otherwise, if ``type`` is set, the selector type will be forced and no @@ -68,10 +73,10 @@ class Selector(_ParselSelector, object_ref): def __init__( self, - response: Optional[TextResponse] = None, - text: Optional[str] = None, - type: Optional[str] = None, - root: Optional[Any] = _NOT_SET, + response: TextResponse | None = None, + text: str | None = None, + type: str | None = None, + root: Any | None = _NOT_SET, **kwargs: Any, ): if response is not None and text is not None: @@ -87,7 +92,7 @@ class Selector(_ParselSelector, object_ref): if response is not None: text = response.text - kwargs.setdefault("base_url", response.url) + kwargs.setdefault("base_url", get_base_url(response)) self.response = response diff --git a/scrapy/settings/__init__.py b/scrapy/settings/__init__.py index b5d8fdb12..274ced3e3 100644 --- a/scrapy/settings/__init__.py +++ b/scrapy/settings/__init__.py @@ -2,23 +2,10 @@ from __future__ import annotations import copy import json +from collections.abc import Iterable, Iterator, Mapping, MutableMapping from importlib import import_module from pprint import pformat -from types import ModuleType -from typing import ( - TYPE_CHECKING, - Any, - Dict, - Iterable, - Iterator, - List, - Mapping, - MutableMapping, - Optional, - Tuple, - Union, - cast, -) +from typing import TYPE_CHECKING, Any, Union, cast from scrapy.settings import default_settings @@ -27,6 +14,8 @@ from scrapy.settings import default_settings _SettingsKeyT = Union[bool, float, int, str, None] if TYPE_CHECKING: + from types import ModuleType + # https://github.com/python/typing/issues/445#issuecomment-1131458824 from _typeshed import SupportsItems @@ -36,7 +25,7 @@ if TYPE_CHECKING: _SettingsInputT = Union[SupportsItems[_SettingsKeyT, Any], str, None] -SETTINGS_PRIORITIES: Dict[str, int] = { +SETTINGS_PRIORITIES: dict[str, int] = { "default": 0, "command": 10, "addon": 15, @@ -46,7 +35,7 @@ SETTINGS_PRIORITIES: Dict[str, int] = { } -def get_settings_priority(priority: Union[int, str]) -> int: +def get_settings_priority(priority: int | str) -> int: """ Small helper function that looks up a given string priority in the :attr:`~scrapy.settings.SETTINGS_PRIORITIES` dictionary and returns its @@ -58,7 +47,6 @@ def get_settings_priority(priority: Union[int, str]) -> int: class SettingsAttribute: - """Class for storing data related to settings attributes. This class is intended for internal usage, you should try Settings class @@ -109,9 +97,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]): __default = object() - def __init__( - self, values: _SettingsInputT = None, priority: Union[int, str] = "project" - ): + def __init__(self, values: _SettingsInputT = None, priority: int | str = "project"): self.frozen: bool = False self.attributes: dict[_SettingsKeyT, SettingsAttribute] = {} if values: @@ -192,8 +178,8 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]): return float(self.get(name, default)) def getlist( - self, name: _SettingsKeyT, default: Optional[List[Any]] = None - ) -> List[Any]: + self, name: _SettingsKeyT, default: list[Any] | None = None + ) -> list[Any]: """ Get a setting value as a list. If the setting original type is a list, a copy of it will be returned. If it's a string it will be split by ",". @@ -213,8 +199,8 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]): return list(value) def getdict( - self, name: _SettingsKeyT, default: Optional[Dict[Any, Any]] = None - ) -> Dict[Any, Any]: + self, name: _SettingsKeyT, default: dict[Any, Any] | None = None + ) -> dict[Any, Any]: """ Get a setting value as a dictionary. If the setting original type is a dictionary, a copy of it will be returned. If it is a string it will be @@ -238,8 +224,8 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]): def getdictorlist( self, name: _SettingsKeyT, - default: Union[Dict[Any, Any], List[Any], Tuple[Any], None] = None, - ) -> Union[Dict[Any, Any], List[Any]]: + 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`. If the setting is already a dict or a list, a copy of it will be @@ -276,7 +262,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]): assert isinstance(value, (dict, list)) return copy.deepcopy(value) - def getwithbase(self, name: _SettingsKeyT) -> "BaseSettings": + def getwithbase(self, name: _SettingsKeyT) -> BaseSettings: """Get a composition of a dictionary-like setting and its `_BASE` counterpart. @@ -290,7 +276,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]): compbs.update(self[name]) return compbs - def getpriority(self, name: _SettingsKeyT) -> Optional[int]: + def getpriority(self, name: _SettingsKeyT) -> int | None: """ Return the current numerical priority value of a setting, or ``None`` if the given ``name`` does not exist. @@ -317,7 +303,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]): self.set(name, value) def set( - self, name: _SettingsKeyT, value: Any, priority: Union[int, str] = "project" + self, name: _SettingsKeyT, value: Any, priority: int | str = "project" ) -> None: """ Store a key/value attribute with a given priority. @@ -350,7 +336,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]): self, name: _SettingsKeyT, default: Any = None, - priority: Union[int, str] = "project", + priority: int | str = "project", ) -> Any: if name not in self: self.set(name, default, priority) @@ -358,13 +344,11 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]): return self.attributes[name].value - def setdict( - self, values: _SettingsInputT, priority: Union[int, str] = "project" - ) -> None: + def setdict(self, values: _SettingsInputT, priority: int | str = "project") -> None: self.update(values, priority) def setmodule( - self, module: Union[ModuleType, str], priority: Union[int, str] = "project" + self, module: ModuleType | str, priority: int | str = "project" ) -> None: """ Store settings from a module with a given priority. @@ -388,7 +372,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: Union[int, str] = "project") -> None: # type: ignore[override] + def update(self, values: _SettingsInputT, priority: int | str = "project") -> None: # type: ignore[override] """ Store key/value pairs with a given priority. @@ -412,7 +396,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]): """ self._assert_mutability() if isinstance(values, str): - values = cast(dict, json.loads(values)) + values = cast(dict[_SettingsKeyT, Any], json.loads(values)) if values is not None: if isinstance(values, BaseSettings): for name, value in values.items(): @@ -421,9 +405,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]): for name, value in values.items(): self.set(name, value, priority) - def delete( - self, name: _SettingsKeyT, priority: Union[int, str] = "project" - ) -> None: + def delete(self, name: _SettingsKeyT, priority: int | str = "project") -> None: if name not in self: raise KeyError(name) self._assert_mutability() @@ -439,7 +421,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]): if self.frozen: raise TypeError("Trying to modify an immutable Settings object") - def copy(self) -> "Self": + def copy(self) -> Self: """ Make a deep copy of current settings. @@ -461,7 +443,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]): """ self.frozen = True - def frozencopy(self) -> "Self": + def frozencopy(self) -> Self: """ Return an immutable copy of the current settings. @@ -477,7 +459,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]): def __len__(self) -> int: return len(self.attributes) - def _to_dict(self) -> Dict[_SettingsKeyT, Any]: + def _to_dict(self) -> dict[_SettingsKeyT, Any]: return { self._get_key(k): (v._to_dict() if isinstance(v, BaseSettings) else v) for k, v in self.items() @@ -490,7 +472,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]): else str(key_value) ) - def copy_to_dict(self) -> Dict[_SettingsKeyT, Any]: + def copy_to_dict(self) -> dict[_SettingsKeyT, Any]: """ Make a copy of current settings and convert to a dict. @@ -537,9 +519,7 @@ class Settings(BaseSettings): described on :ref:`topics-settings-ref` already populated. """ - def __init__( - self, values: _SettingsInputT = None, priority: Union[int, str] = "project" - ): + def __init__(self, values: _SettingsInputT = 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 @@ -553,7 +533,7 @@ class Settings(BaseSettings): self.update(values, priority) -def iter_default_settings() -> Iterable[Tuple[str, Any]]: +def iter_default_settings() -> Iterable[tuple[str, Any]]: """Return the default settings as an iterator of (name, value) tuples""" for name in dir(default_settings): if name.isupper(): @@ -562,7 +542,7 @@ def iter_default_settings() -> Iterable[Tuple[str, Any]]: def overridden_settings( settings: Mapping[_SettingsKeyT, Any] -) -> Iterable[Tuple[str, Any]]: +) -> Iterable[tuple[str, Any]]: """Return an iterable of the settings that have been overridden""" for name, defvalue in iter_default_settings(): value = settings[name] diff --git a/scrapy/settings/default_settings.py b/scrapy/settings/default_settings.py index 49ab1b5ef..7ba0128a5 100644 --- a/scrapy/settings/default_settings.py +++ b/scrapy/settings/default_settings.py @@ -101,6 +101,7 @@ DOWNLOADER_MIDDLEWARES = {} DOWNLOADER_MIDDLEWARES_BASE = { # Engine side + "scrapy.downloadermiddlewares.offsite.OffsiteMiddleware": 50, "scrapy.downloadermiddlewares.robotstxt.RobotsTxtMiddleware": 100, "scrapy.downloadermiddlewares.httpauth.HttpAuthMiddleware": 300, "scrapy.downloadermiddlewares.downloadtimeout.DownloadTimeoutMiddleware": 350, @@ -177,7 +178,7 @@ FILES_STORE_S3_ACL = "private" FILES_STORE_GCS_ACL = "" FTP_USER = "anonymous" -FTP_PASSWORD = "guest" +FTP_PASSWORD = "guest" # nosec FTP_PASSIVE_MODE = True GCS_PROJECT_ID = None @@ -239,7 +240,7 @@ MEMUSAGE_NOTIFY_MAIL = [] MEMUSAGE_WARNING_MB = 0 METAREFRESH_ENABLED = True -METAREFRESH_IGNORE_TAGS = [] +METAREFRESH_IGNORE_TAGS = ["noscript"] METAREFRESH_MAXDELAY = 100 NEWSPIDER_MODULE = "" @@ -301,7 +302,6 @@ SPIDER_MIDDLEWARES = {} SPIDER_MIDDLEWARES_BASE = { # Engine side "scrapy.spidermiddlewares.httperror.HttpErrorMiddleware": 50, - "scrapy.spidermiddlewares.offsite.OffsiteMiddleware": 500, "scrapy.spidermiddlewares.referer.RefererMiddleware": 700, "scrapy.spidermiddlewares.urllength.UrlLengthMiddleware": 800, "scrapy.spidermiddlewares.depth.DepthMiddleware": 900, @@ -333,6 +333,7 @@ SPIDER_CONTRACTS = {} SPIDER_CONTRACTS_BASE = { "scrapy.contracts.default.UrlContract": 1, "scrapy.contracts.default.CallbackKeywordArgumentsContract": 1, + "scrapy.contracts.default.MetadataContract": 1, "scrapy.contracts.default.ReturnsContract": 2, "scrapy.contracts.default.ScrapesContract": 3, } diff --git a/scrapy/shell.py b/scrapy/shell.py index bb3b1461c..31349c4ff 100644 --- a/scrapy/shell.py +++ b/scrapy/shell.py @@ -3,8 +3,12 @@ See documentation in docs/topics/shell.rst """ + +from __future__ import annotations + import os import signal +from typing import TYPE_CHECKING, Any from itemadapter import is_item from twisted.internet import defer, threads @@ -23,20 +27,37 @@ 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 +if TYPE_CHECKING: + from collections.abc import Callable + class Shell: - relevant_classes = (Crawler, Spider, Request, Response, Settings) + relevant_classes: tuple[type, ...] = (Crawler, Spider, Request, Response, Settings) - def __init__(self, crawler, update_vars=None, code=None): - self.crawler = crawler - self.update_vars = update_vars or (lambda x: None) - self.item_class = load_object(crawler.settings["DEFAULT_ITEM_CLASS"]) - self.spider = None - self.inthread = not threadable.isInIOThread() - self.code = code - self.vars = {} + def __init__( + self, + crawler: Crawler, + update_vars: Callable[[dict[str, Any]], None] | None = None, + code: str | None = None, + ): + self.crawler: Crawler = crawler + self.update_vars: Callable[[dict[str, Any]], None] = update_vars or ( + lambda x: None + ) + self.item_class: type = load_object(crawler.settings["DEFAULT_ITEM_CLASS"]) + self.spider: Spider | None = None + self.inthread: bool = not threadable.isInIOThread() + self.code: str | None = code + self.vars: dict[str, Any] = {} - def start(self, url=None, request=None, response=None, spider=None, redirect=True): + def start( + self, + url: str | None = None, + request: Request | None = None, + response: Response | None = None, + spider: Spider | None = None, + redirect: bool = True, + ) -> None: # disable accidental Ctrl-C key press from shutting down the engine signal.signal(signal.SIGINT, signal.SIG_IGN) if url: @@ -49,7 +70,7 @@ class Shell: else: self.populate_vars() if self.code: - print(eval(self.code, globals(), self.vars)) + print(eval(self.code, globals(), self.vars)) # nosec else: """ Detect interactive shell setting in scrapy.cfg @@ -76,7 +97,7 @@ class Shell: self.vars, shells=shells, banner=self.vars.pop("banner", "") ) - def _schedule(self, request, spider): + def _schedule(self, request: Request, spider: Spider | None) -> defer.Deferred[Any]: if is_asyncio_reactor_installed(): # set the asyncio event loop for the current thread event_loop_path = self.crawler.settings["ASYNCIO_EVENT_LOOP"] @@ -84,10 +105,11 @@ class Shell: spider = self._open_spider(request, spider) d = _request_deferred(request) d.addCallback(lambda x: (x, spider)) + assert self.crawler.engine self.crawler.engine.crawl(request) return d - def _open_spider(self, request, spider): + def _open_spider(self, request: Request, spider: Spider | None) -> Spider: if self.spider: return self.spider @@ -95,11 +117,18 @@ class Shell: spider = self.crawler.spider or self.crawler._create_spider() self.crawler.spider = spider + assert self.crawler.engine self.crawler.engine.open_spider(spider, close_if_idle=False) self.spider = spider return spider - def fetch(self, request_or_url, spider=None, redirect=True, **kwargs): + def fetch( + self, + request_or_url: Request | str, + spider: Spider | None = None, + redirect: bool = True, + **kwargs: Any, + ) -> None: from twisted.internet import reactor if isinstance(request_or_url, Request): @@ -122,7 +151,12 @@ class Shell: pass self.populate_vars(response, request, spider) - def populate_vars(self, response=None, request=None, spider=None): + def populate_vars( + self, + response: Response | None = None, + request: Request | None = None, + spider: Spider | None = None, + ) -> None: import scrapy self.vars["scrapy"] = scrapy @@ -140,10 +174,10 @@ class Shell: if not self.code: self.vars["banner"] = self.get_help() - def print_help(self): + def print_help(self) -> None: print(self.get_help()) - def get_help(self): + def get_help(self) -> str: b = [] b.append("Available Scrapy objects:") b.append( @@ -167,11 +201,11 @@ class Shell: return "\n".join(f"[s] {line}" for line in b) - def _is_relevant(self, value): + def _is_relevant(self, value: Any) -> bool: return isinstance(value, self.relevant_classes) or is_item(value) -def inspect_response(response, spider): +def inspect_response(response: Response, spider: Spider) -> None: """Open a shell to inspect the given response""" # Shell.start removes the SIGINT handler, so save it and re-add it after # the shell has closed @@ -180,7 +214,7 @@ def inspect_response(response, spider): signal.signal(signal.SIGINT, sigint_handler) -def _request_deferred(request): +def _request_deferred(request: Request) -> defer.Deferred[Any]: """Wrap a request inside a Deferred. This function is harmful, do not use it until you know what you are doing. @@ -194,15 +228,17 @@ def _request_deferred(request): request_callback = request.callback request_errback = request.errback - def _restore_callbacks(result): + def _restore_callbacks(result: Any) -> Any: request.callback = request_callback request.errback = request_errback return result - d = defer.Deferred() + d: defer.Deferred[Any] = defer.Deferred() d.addBoth(_restore_callbacks) if request.callback: - d.addCallbacks(request.callback, request.errback) + d.addCallback(request.callback) + if request.errback: + d.addErrback(request.errback) request.callback, request.errback = d.callback, d.errback return d diff --git a/scrapy/signalmanager.py b/scrapy/signalmanager.py index f6df191d8..e106418d6 100644 --- a/scrapy/signalmanager.py +++ b/scrapy/signalmanager.py @@ -1,10 +1,14 @@ -from typing import Any, List, Tuple +from __future__ import annotations + +from typing import TYPE_CHECKING, Any from pydispatch import dispatcher -from twisted.internet.defer import Deferred from scrapy.utils import signal as _signal +if TYPE_CHECKING: + from twisted.internet.defer import Deferred + class SignalManager: def __init__(self, sender: Any = dispatcher.Anonymous): @@ -36,7 +40,7 @@ class SignalManager: kwargs.setdefault("sender", self.sender) dispatcher.disconnect(receiver, signal, **kwargs) - def send_catch_log(self, signal: Any, **kwargs: Any) -> List[Tuple[Any, Any]]: + def send_catch_log(self, signal: Any, **kwargs: Any) -> list[tuple[Any, Any]]: """ Send a signal, catch exceptions and log them. @@ -46,7 +50,9 @@ class SignalManager: kwargs.setdefault("sender", self.sender) return _signal.send_catch_log(signal, **kwargs) - def send_catch_log_deferred(self, signal: Any, **kwargs: Any) -> Deferred: + def send_catch_log_deferred( + self, signal: Any, **kwargs: Any + ) -> Deferred[list[tuple[Any, Any]]]: """ Like :meth:`send_catch_log` but supports returning :class:`~twisted.internet.defer.Deferred` objects from signal handlers. diff --git a/scrapy/spiderloader.py b/scrapy/spiderloader.py index d855c962c..210e729a1 100644 --- a/scrapy/spiderloader.py +++ b/scrapy/spiderloader.py @@ -3,21 +3,23 @@ from __future__ import annotations import traceback import warnings from collections import defaultdict -from types import ModuleType -from typing import TYPE_CHECKING, DefaultDict, Dict, List, Tuple, Type +from typing import TYPE_CHECKING from zope.interface import implementer -from scrapy import Request, Spider from scrapy.interfaces import ISpiderLoader -from scrapy.settings import BaseSettings from scrapy.utils.misc import walk_modules from scrapy.utils.spider import iter_spider_classes if TYPE_CHECKING: + from types import ModuleType + # typing.Self requires Python 3.11 from typing_extensions import Self + from scrapy import Request, Spider + from scrapy.settings import BaseSettings + @implementer(ISpiderLoader) class SpiderLoader: @@ -27,10 +29,10 @@ class SpiderLoader: """ def __init__(self, settings: BaseSettings): - self.spider_modules: List[str] = settings.getlist("SPIDER_MODULES") + self.spider_modules: list[str] = settings.getlist("SPIDER_MODULES") self.warn_only: bool = settings.getbool("SPIDER_LOADER_WARN_ONLY") - self._spiders: Dict[str, Type[Spider]] = {} - self._found: DefaultDict[str, List[Tuple[str, str]]] = defaultdict(list) + self._spiders: dict[str, type[Spider]] = {} + self._found: defaultdict[str, list[tuple[str, str]]] = defaultdict(list) self._load_all_spiders() def _check_name_duplicates(self) -> None: @@ -62,7 +64,7 @@ class SpiderLoader: try: for module in walk_modules(name): self._load_spiders(module) - except ImportError: + except (ImportError, SyntaxError): if self.warn_only: warnings.warn( f"\n{traceback.format_exc()}Could not load spiders " @@ -78,7 +80,7 @@ class SpiderLoader: def from_settings(cls, settings: BaseSettings) -> Self: return cls(settings) - def load(self, spider_name: str) -> Type[Spider]: + def load(self, spider_name: str) -> type[Spider]: """ Return the Spider class for the given spider name. If the spider name is not found, raise a KeyError. @@ -88,7 +90,7 @@ class SpiderLoader: except KeyError: raise KeyError(f"Spider not found: {spider_name}") - def find_by_request(self, request: Request) -> List[str]: + def find_by_request(self, request: Request) -> list[str]: """ Return the list of spider names that can handle the given request. """ @@ -96,7 +98,7 @@ class SpiderLoader: name for name, cls in self._spiders.items() if cls.handles_request(request) ] - def list(self) -> List[str]: + def list(self) -> list[str]: """ Return a list with the names of all spiders available in the project. """ diff --git a/scrapy/spidermiddlewares/depth.py b/scrapy/spidermiddlewares/depth.py index 1e96654e2..3164c1c03 100644 --- a/scrapy/spidermiddlewares/depth.py +++ b/scrapy/spidermiddlewares/depth.py @@ -7,17 +7,21 @@ See documentation in docs/topics/spider-middleware.rst from __future__ import annotations import logging -from typing import TYPE_CHECKING, Any, AsyncIterable, Iterable +from typing import TYPE_CHECKING, Any -from scrapy import Spider -from scrapy.crawler import Crawler from scrapy.http import Request, Response -from scrapy.statscollectors import StatsCollector if TYPE_CHECKING: + from collections.abc import AsyncIterable, Iterable + # typing.Self requires Python 3.11 from typing_extensions import Self + from scrapy import Spider + from scrapy.crawler import Crawler + from scrapy.statscollectors import StatsCollector + + logger = logging.getLogger(__name__) diff --git a/scrapy/spidermiddlewares/httperror.py b/scrapy/spidermiddlewares/httperror.py index 94450b35b..42619ec7f 100644 --- a/scrapy/spidermiddlewares/httperror.py +++ b/scrapy/spidermiddlewares/httperror.py @@ -3,21 +3,26 @@ HttpError Spider Middleware See documentation in docs/topics/spider-middleware.rst """ + from __future__ import annotations import logging -from typing import TYPE_CHECKING, Any, Iterable, List, Optional +from typing import TYPE_CHECKING, Any -from scrapy import Spider -from scrapy.crawler import Crawler from scrapy.exceptions import IgnoreRequest -from scrapy.http import Response -from scrapy.settings import BaseSettings if TYPE_CHECKING: + from collections.abc import Iterable + # typing.Self requires Python 3.11 from typing_extensions import Self + from scrapy import Spider + from scrapy.crawler import Crawler + from scrapy.http import Response + from scrapy.settings import BaseSettings + + logger = logging.getLogger(__name__) @@ -36,7 +41,7 @@ class HttpErrorMiddleware: def __init__(self, settings: BaseSettings): self.handle_httpstatus_all: bool = settings.getbool("HTTPERROR_ALLOW_ALL") - self.handle_httpstatus_list: List[int] = settings.getlist( + self.handle_httpstatus_list: list[int] = settings.getlist( "HTTPERROR_ALLOWED_CODES" ) @@ -60,7 +65,7 @@ class HttpErrorMiddleware: def process_spider_exception( self, response: Response, exception: Exception, spider: Spider - ) -> Optional[Iterable[Any]]: + ) -> Iterable[Any] | None: if isinstance(exception, HttpError): assert spider.crawler.stats spider.crawler.stats.inc_value("httperror/response_ignored_count") diff --git a/scrapy/spidermiddlewares/offsite.py b/scrapy/spidermiddlewares/offsite.py index a5214702d..d3ed64ef5 100644 --- a/scrapy/spidermiddlewares/offsite.py +++ b/scrapy/spidermiddlewares/offsite.py @@ -3,23 +3,34 @@ 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, Any, AsyncIterable, Iterable, Set +from typing import TYPE_CHECKING, Any from scrapy import Spider, signals -from scrapy.crawler import Crawler +from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.http import Request, Response -from scrapy.statscollectors import StatsCollector 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: + from collections.abc import AsyncIterable, Iterable + # typing.Self requires Python 3.11 from typing_extensions import Self + from scrapy.crawler import Crawler + from scrapy.statscollectors import StatsCollector + logger = logging.getLogger(__name__) @@ -100,7 +111,7 @@ class OffsiteMiddleware: def spider_opened(self, spider: Spider) -> None: self.host_regex: re.Pattern[str] = self.get_host_regex(spider) - self.domains_seen: Set[str] = set() + self.domains_seen: set[str] = set() class URLWarning(Warning): diff --git a/scrapy/spidermiddlewares/referer.py b/scrapy/spidermiddlewares/referer.py index a29e0ebb5..bdf1f168a 100644 --- a/scrapy/spidermiddlewares/referer.py +++ b/scrapy/spidermiddlewares/referer.py @@ -2,39 +2,33 @@ RefererMiddleware: populates Request referer field, based on the Response which originated it. """ + from __future__ import annotations import warnings -from typing import ( - TYPE_CHECKING, - Any, - AsyncIterable, - Dict, - Iterable, - Optional, - Tuple, - Type, - Union, - cast, -) +from typing import TYPE_CHECKING, Any, cast from urllib.parse import urlparse from w3lib.url import safe_url_string from scrapy import Spider, signals -from scrapy.crawler import Crawler from scrapy.exceptions import NotConfigured from scrapy.http import Request, Response -from scrapy.settings import BaseSettings from scrapy.utils.misc import load_object from scrapy.utils.python import to_unicode from scrapy.utils.url import strip_url if TYPE_CHECKING: + from collections.abc import AsyncIterable, Iterable + # typing.Self requires Python 3.11 from typing_extensions import Self -LOCAL_SCHEMES: Tuple[str, ...] = ( + from scrapy.crawler import Crawler + from scrapy.settings import BaseSettings + + +LOCAL_SCHEMES: tuple[str, ...] = ( "about", "blob", "data", @@ -53,23 +47,23 @@ POLICY_SCRAPY_DEFAULT = "scrapy-default" class ReferrerPolicy: - NOREFERRER_SCHEMES: Tuple[str, ...] = LOCAL_SCHEMES + NOREFERRER_SCHEMES: tuple[str, ...] = LOCAL_SCHEMES name: str - def referrer(self, response_url: str, request_url: str) -> Optional[str]: + def referrer(self, response_url: str, request_url: str) -> str | None: raise NotImplementedError() - def stripped_referrer(self, url: str) -> Optional[str]: + def stripped_referrer(self, url: str) -> str | None: if urlparse(url).scheme not in self.NOREFERRER_SCHEMES: return self.strip_url(url) return None - def origin_referrer(self, url: str) -> Optional[str]: + def origin_referrer(self, url: str) -> str | None: if urlparse(url).scheme not in self.NOREFERRER_SCHEMES: return self.origin(url) return None - def strip_url(self, url: str, origin_only: bool = False) -> Optional[str]: + def strip_url(self, url: str, origin_only: bool = False) -> str | None: """ https://www.w3.org/TR/referrer-policy/#strip-url @@ -93,7 +87,7 @@ class ReferrerPolicy: origin_only=origin_only, ) - def origin(self, url: str) -> Optional[str]: + def origin(self, url: str) -> str | None: """Return serialized origin (scheme, host, path) for a request or response URL.""" return self.strip_url(url, origin_only=True) @@ -119,7 +113,7 @@ class NoReferrerPolicy(ReferrerPolicy): name: str = POLICY_NO_REFERRER - def referrer(self, response_url: str, request_url: str) -> Optional[str]: + def referrer(self, response_url: str, request_url: str) -> str | None: return None @@ -140,7 +134,7 @@ class NoReferrerWhenDowngradePolicy(ReferrerPolicy): name: str = POLICY_NO_REFERRER_WHEN_DOWNGRADE - def referrer(self, response_url: str, request_url: str) -> Optional[str]: + def referrer(self, response_url: str, request_url: str) -> str | None: if not self.tls_protected(response_url) or self.tls_protected(request_url): return self.stripped_referrer(response_url) return None @@ -159,7 +153,7 @@ class SameOriginPolicy(ReferrerPolicy): name: str = POLICY_SAME_ORIGIN - def referrer(self, response_url: str, request_url: str) -> Optional[str]: + def referrer(self, response_url: str, request_url: str) -> str | None: if self.origin(response_url) == self.origin(request_url): return self.stripped_referrer(response_url) return None @@ -177,7 +171,7 @@ class OriginPolicy(ReferrerPolicy): name: str = POLICY_ORIGIN - def referrer(self, response_url: str, request_url: str) -> Optional[str]: + def referrer(self, response_url: str, request_url: str) -> str | None: return self.origin_referrer(response_url) @@ -197,7 +191,7 @@ class StrictOriginPolicy(ReferrerPolicy): name: str = POLICY_STRICT_ORIGIN - def referrer(self, response_url: str, request_url: str) -> Optional[str]: + def referrer(self, response_url: str, request_url: str) -> str | None: if ( self.tls_protected(response_url) and self.potentially_trustworthy(request_url) @@ -221,7 +215,7 @@ class OriginWhenCrossOriginPolicy(ReferrerPolicy): name: str = POLICY_ORIGIN_WHEN_CROSS_ORIGIN - def referrer(self, response_url: str, request_url: str) -> Optional[str]: + def referrer(self, response_url: str, request_url: str) -> str | None: origin = self.origin(response_url) if origin == self.origin(request_url): return self.stripped_referrer(response_url) @@ -248,7 +242,7 @@ class StrictOriginWhenCrossOriginPolicy(ReferrerPolicy): name: str = POLICY_STRICT_ORIGIN_WHEN_CROSS_ORIGIN - def referrer(self, response_url: str, request_url: str) -> Optional[str]: + def referrer(self, response_url: str, request_url: str) -> str | None: origin = self.origin(response_url) if origin == self.origin(request_url): return self.stripped_referrer(response_url) @@ -277,7 +271,7 @@ class UnsafeUrlPolicy(ReferrerPolicy): name: str = POLICY_UNSAFE_URL - def referrer(self, response_url: str, request_url: str) -> Optional[str]: + def referrer(self, response_url: str, request_url: str) -> str | None: return self.stripped_referrer(response_url) @@ -288,11 +282,11 @@ class DefaultReferrerPolicy(NoReferrerWhenDowngradePolicy): using ``file://`` or ``s3://`` scheme. """ - NOREFERRER_SCHEMES: Tuple[str, ...] = LOCAL_SCHEMES + ("file", "s3") + NOREFERRER_SCHEMES: tuple[str, ...] = LOCAL_SCHEMES + ("file", "s3") name: str = POLICY_SCRAPY_DEFAULT -_policy_classes: Dict[str, Type[ReferrerPolicy]] = { +_policy_classes: dict[str, type[ReferrerPolicy]] = { p.name: p for p in ( NoReferrerPolicy, @@ -313,29 +307,32 @@ _policy_classes[""] = NoReferrerWhenDowngradePolicy def _load_policy_class( policy: str, warning_only: bool = False -) -> Optional[Type[ReferrerPolicy]]: +) -> type[ReferrerPolicy] | None: """ Expect a string for the path to the policy class, otherwise try to interpret the string as a standard value 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: - try: - return _policy_classes[policy.lower()] - except KeyError: - msg = f"Could not load referrer policy {policy!r}" - if not warning_only: - raise RuntimeError(msg) - else: - warnings.warn(msg, RuntimeWarning) - return None + tokens = [token.strip() for token in policy.lower().split(",")] + # https://www.w3.org/TR/referrer-policy/#parse-referrer-policy-from-header + for token in tokens[::-1]: + if token in _policy_classes: + return _policy_classes[token] + + msg = f"Could not load referrer policy {policy!r}" + if not warning_only: + raise RuntimeError(msg) + else: + warnings.warn(msg, RuntimeWarning) + return None class RefererMiddleware: - def __init__(self, settings: Optional[BaseSettings] = None): - self.default_policy: Type[ReferrerPolicy] = DefaultReferrerPolicy + def __init__(self, settings: BaseSettings | None = None): + self.default_policy: type[ReferrerPolicy] = DefaultReferrerPolicy if settings is not None: settings_policy = _load_policy_class(settings.get("REFERRER_POLICY")) assert settings_policy @@ -352,9 +349,7 @@ class RefererMiddleware: return mw - def policy( - self, resp_or_url: Union[Response, str], request: Request - ) -> ReferrerPolicy: + def policy(self, resp_or_url: Response | str, request: Request) -> ReferrerPolicy: """ Determine Referrer-Policy to use from a parent Response (or URL), and a Request to be sent. diff --git a/scrapy/spidermiddlewares/urllength.py b/scrapy/spidermiddlewares/urllength.py index e2aa554a7..191adb6cd 100644 --- a/scrapy/spidermiddlewares/urllength.py +++ b/scrapy/spidermiddlewares/urllength.py @@ -7,17 +7,21 @@ See documentation in docs/topics/spider-middleware.rst from __future__ import annotations import logging -from typing import TYPE_CHECKING, Any, AsyncIterable, Iterable +from typing import TYPE_CHECKING, Any -from scrapy import Spider from scrapy.exceptions import NotConfigured from scrapy.http import Request, Response -from scrapy.settings import BaseSettings if TYPE_CHECKING: + from collections.abc import AsyncIterable, Iterable + # typing.Self requires Python 3.11 from typing_extensions import Self + from scrapy import Spider + from scrapy.settings import BaseSettings + + logger = logging.getLogger(__name__) diff --git a/scrapy/spiders/__init__.py b/scrapy/spiders/__init__.py index e16d71727..6136dabc7 100644 --- a/scrapy/spiders/__init__.py +++ b/scrapy/spiders/__init__.py @@ -3,12 +3,11 @@ Base class for Scrapy spiders See documentation in docs/topics/spiders.rst """ + from __future__ import annotations import logging -from typing import TYPE_CHECKING, Any, Iterable, List, Optional, Union, cast - -from twisted.internet.defer import Deferred +from typing import TYPE_CHECKING, Any, cast from scrapy import signals from scrapy.http import Request, Response @@ -16,11 +15,17 @@ from scrapy.utils.trackref import object_ref from scrapy.utils.url import url_is_from_spider if TYPE_CHECKING: + from collections.abc import Iterable + + from twisted.internet.defer import Deferred + # typing.Self requires Python 3.11 from typing_extensions import Self from scrapy.crawler import Crawler - from scrapy.settings import BaseSettings + from scrapy.http.request import CallbackT + from scrapy.settings import BaseSettings, _SettingsKeyT + from scrapy.utils.log import SpiderLoggerAdapter class Spider(object_ref): @@ -29,21 +34,23 @@ class Spider(object_ref): """ name: str - custom_settings: Optional[dict] = None + custom_settings: dict[_SettingsKeyT, Any] | None = None - def __init__(self, name: Optional[str] = None, **kwargs: Any): + def __init__(self, name: str | None = None, **kwargs: Any): if name is not None: - self.name = name + self.name: str = name elif not getattr(self, "name", None): raise ValueError(f"{type(self).__name__} must have a name") self.__dict__.update(kwargs) if not hasattr(self, "start_urls"): - self.start_urls: List[str] = [] + self.start_urls: list[str] = [] @property - def logger(self) -> logging.LoggerAdapter: + def logger(self) -> SpiderLoggerAdapter: + from scrapy.utils.log import SpiderLoggerAdapter + logger = logging.getLogger(self.name) - return logging.LoggerAdapter(logger, {"spider": self}) + return SpiderLoggerAdapter(logger, {"spider": self}) def log(self, message: Any, level: int = logging.DEBUG, **kw: Any) -> None: """Log the given message at the given log level @@ -61,8 +68,8 @@ class Spider(object_ref): return spider def _set_crawler(self, crawler: Crawler) -> None: - self.crawler = crawler - self.settings = crawler.settings + self.crawler: Crawler = crawler + self.settings: BaseSettings = crawler.settings crawler.signals.connect(self.close, signals.spider_closed) def start_requests(self) -> Iterable[Request]: @@ -78,10 +85,14 @@ class Spider(object_ref): def _parse(self, response: Response, **kwargs: Any) -> Any: return self.parse(response, **kwargs) - def parse(self, response: Response, **kwargs: Any) -> Any: - raise NotImplementedError( - f"{self.__class__.__name__}.parse callback is not defined" - ) + if TYPE_CHECKING: + parse: CallbackT + else: + + def parse(self, response: Response, **kwargs: Any) -> Any: + raise NotImplementedError( + f"{self.__class__.__name__}.parse callback is not defined" + ) @classmethod def update_settings(cls, settings: BaseSettings) -> None: @@ -92,10 +103,10 @@ class Spider(object_ref): return url_is_from_spider(request.url, cls) @staticmethod - def close(spider: Spider, reason: str) -> Union[Deferred, None]: + def close(spider: Spider, reason: str) -> Deferred[None] | None: closed = getattr(spider, "closed", None) if callable(closed): - return cast(Union[Deferred, None], closed(reason)) + return cast("Deferred[None] | None", closed(reason)) return None def __repr__(self) -> str: diff --git a/scrapy/spiders/crawl.py b/scrapy/spiders/crawl.py index ebb4f5984..087049425 100644 --- a/scrapy/spiders/crawl.py +++ b/scrapy/spiders/crawl.py @@ -1,33 +1,54 @@ """ This modules implements the CrawlSpider which is the recommended spider to use -for scraping typical web sites that requires crawling pages. +for scraping typical websites that requires crawling pages. See documentation in docs/topics/spiders.rst """ +from __future__ import annotations + import copy -from typing import AsyncIterable, Awaitable, Sequence +from collections.abc import AsyncIterable, Awaitable, Callable +from typing import TYPE_CHECKING, Any, Optional, TypeVar, cast + +from twisted.python.failure import Failure from scrapy.http import HtmlResponse, Request, Response +from scrapy.link import Link from scrapy.linkextractors import LinkExtractor from scrapy.spiders import Spider from scrapy.utils.asyncgen import collect_asyncgen from scrapy.utils.spider import iterate_spider_output +if TYPE_CHECKING: + from collections.abc import Iterable, Sequence -def _identity(x): + # typing.Self requires Python 3.11 + from typing_extensions import Self + + from scrapy.crawler import Crawler + from scrapy.http.request import CallbackT + + +_T = TypeVar("_T") +ProcessLinksT = Callable[[list[Link]], list[Link]] +ProcessRequestT = Callable[[Request, Response], Optional[Request]] + + +def _identity(x: _T) -> _T: return x -def _identity_process_request(request, response): +def _identity_process_request(request: Request, response: Response) -> Request | None: return request -def _get_method(method, spider): +def _get_method(method: Callable | str | None, spider: Spider) -> Callable | None: if callable(method): return method if isinstance(method, str): return getattr(spider, method, None) + return None _default_link_extractor = LinkExtractor() @@ -36,37 +57,46 @@ _default_link_extractor = LinkExtractor() class Rule: def __init__( self, - link_extractor=None, - callback=None, - cb_kwargs=None, - follow=None, - process_links=None, - process_request=None, - errback=None, + link_extractor: LinkExtractor | None = None, + callback: CallbackT | str | None = None, + cb_kwargs: dict[str, Any] | None = None, + follow: bool | None = None, + process_links: ProcessLinksT | str | None = None, + process_request: ProcessRequestT | str | None = None, + errback: Callable[[Failure], Any] | str | None = None, ): - self.link_extractor = link_extractor or _default_link_extractor - self.callback = callback - self.errback = errback - self.cb_kwargs = cb_kwargs or {} - self.process_links = process_links or _identity - self.process_request = process_request or _identity_process_request - self.follow = follow if follow is not None else not callback + self.link_extractor: LinkExtractor = link_extractor or _default_link_extractor + self.callback: CallbackT | str | None = callback + self.errback: Callable[[Failure], Any] | str | None = errback + self.cb_kwargs: dict[str, Any] = cb_kwargs or {} + self.process_links: ProcessLinksT | str = process_links or _identity + self.process_request: ProcessRequestT | str = ( + process_request or _identity_process_request + ) + self.follow: bool = follow if follow is not None else not callback - def _compile(self, spider): - self.callback = _get_method(self.callback, spider) - self.errback = _get_method(self.errback, spider) - self.process_links = _get_method(self.process_links, spider) - self.process_request = _get_method(self.process_request, spider) + 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.process_links = cast( + ProcessLinksT, _get_method(self.process_links, spider) + ) + self.process_request = cast( + ProcessRequestT, _get_method(self.process_request, spider) + ) class CrawlSpider(Spider): rules: Sequence[Rule] = () + _rules: list[Rule] + _follow_links: bool - def __init__(self, *a, **kw): + def __init__(self, *a: Any, **kw: Any): super().__init__(*a, **kw) self._compile_rules() - def _parse(self, response, **kwargs): + def _parse(self, response: Response, **kwargs: Any) -> Any: return self._parse_response( response=response, callback=self.parse_start_url, @@ -74,46 +104,59 @@ class CrawlSpider(Spider): follow=True, ) - def parse_start_url(self, response, **kwargs): + def parse_start_url(self, response: Response, **kwargs: Any) -> Any: return [] - def process_results(self, response: Response, results: list): + def process_results( + self, response: Response, results: Iterable[Any] + ) -> Iterable[Any]: return results - def _build_request(self, rule_index, link): + def _build_request(self, rule_index: int, link: Link) -> Request: return Request( url=link.url, callback=self._callback, errback=self._errback, - meta=dict(rule=rule_index, link_text=link.text), + meta={"rule": rule_index, "link_text": link.text}, ) - def _requests_to_follow(self, response): + def _requests_to_follow(self, response: Response) -> Iterable[Request | None]: if not isinstance(response, HtmlResponse): return - seen = set() + seen: set[Link] = set() for rule_index, rule in enumerate(self._rules): - links = [ + links: list[Link] = [ lnk for lnk in rule.link_extractor.extract_links(response) if lnk not in seen ] - for link in rule.process_links(links): + for link in cast(ProcessLinksT, rule.process_links)(links): seen.add(link) request = self._build_request(rule_index, link) - yield rule.process_request(request, response) + yield cast(ProcessRequestT, rule.process_request)(request, response) - def _callback(self, response, **cb_kwargs): - rule = self._rules[response.meta["rule"]] + def _callback(self, response: Response, **cb_kwargs: Any) -> Any: + rule = self._rules[cast(int, response.meta["rule"])] return self._parse_response( - response, rule.callback, {**rule.cb_kwargs, **cb_kwargs}, rule.follow + response, + cast("CallbackT", rule.callback), + {**rule.cb_kwargs, **cb_kwargs}, + rule.follow, ) - def _errback(self, failure): - rule = self._rules[failure.request.meta["rule"]] - return self._handle_failure(failure, rule.errback) + def _errback(self, failure: Failure) -> Iterable[Any]: + rule = self._rules[cast(int, failure.request.meta["rule"])] # type: ignore[attr-defined] + return self._handle_failure( + failure, cast(Callable[[Failure], Any], rule.errback) + ) - async def _parse_response(self, response, callback, cb_kwargs, follow=True): + async def _parse_response( + self, + response: Response, + callback: CallbackT | None, + cb_kwargs: dict[str, Any], + follow: bool = True, + ) -> AsyncIterable[Any]: if callback: cb_res = callback(response, **cb_kwargs) or () if isinstance(cb_res, AsyncIterable): @@ -128,19 +171,21 @@ class CrawlSpider(Spider): for request_or_item in self._requests_to_follow(response): yield request_or_item - def _handle_failure(self, failure, errback): + def _handle_failure( + self, failure: Failure, errback: Callable[[Failure], Any] | None + ) -> Iterable[Any]: if errback: results = errback(failure) or () yield from iterate_spider_output(results) - def _compile_rules(self): + def _compile_rules(self) -> None: self._rules = [] for rule in self.rules: self._rules.append(copy.copy(rule)) self._rules[-1]._compile(self) @classmethod - def from_crawler(cls, crawler, *args, **kwargs): + def from_crawler(cls, crawler: Crawler, *args: Any, **kwargs: Any) -> Self: spider = super().from_crawler(crawler, *args, **kwargs) spider._follow_links = crawler.settings.getbool( "CRAWLSPIDER_FOLLOW_LINKS", True diff --git a/scrapy/spiders/feed.py b/scrapy/spiders/feed.py index 47827e442..395183613 100644 --- a/scrapy/spiders/feed.py +++ b/scrapy/spiders/feed.py @@ -4,12 +4,21 @@ for scraping from an XML feed. See documentation in docs/topics/spiders.rst """ + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + from scrapy.exceptions import NotConfigured, NotSupported +from scrapy.http import Response, TextResponse from scrapy.selector import Selector from scrapy.spiders import Spider -from scrapy.utils.iterators import csviter, xmliter +from scrapy.utils.iterators import csviter, xmliter_lxml from scrapy.utils.spider import iterate_spider_output +if TYPE_CHECKING: + from collections.abc import Iterable, Sequence + class XMLFeedSpider(Spider): """ @@ -21,11 +30,13 @@ class XMLFeedSpider(Spider): use iternodes, since it's a faster and cleaner. """ - iterator = "iternodes" - itertag = "item" - namespaces = () + iterator: str = "iternodes" + itertag: str = "item" + namespaces: Sequence[tuple[str, str]] = () - def process_results(self, response, results): + def process_results( + self, response: Response, results: Iterable[Any] + ) -> Iterable[Any]: """This overridable method is called for each result (item or request) returned by the spider, and it's intended to perform any last time processing required before returning the results to the framework core, @@ -35,20 +46,20 @@ class XMLFeedSpider(Spider): """ return results - def adapt_response(self, response): + def adapt_response(self, response: Response) -> Response: """You can override this function in order to make any changes you want to into the feed before parsing it. This function must return a response. """ return response - def parse_node(self, response, selector): + def parse_node(self, response: Response, selector: Selector) -> Any: """This method must be overridden with your custom spider functionality""" if hasattr(self, "parse_item"): # backward compatibility return self.parse_item(response, selector) raise NotImplementedError - def parse_nodes(self, response, nodes): + def parse_nodes(self, response: Response, nodes: Iterable[Selector]) -> Any: """This method is called for the nodes matching the provided tag name (itertag). Receives the response and an Selector for each node. Overriding this method is mandatory. Otherwise, you spider won't work. @@ -60,20 +71,25 @@ class XMLFeedSpider(Spider): ret = iterate_spider_output(self.parse_node(response, selector)) yield from self.process_results(response, ret) - def _parse(self, response, **kwargs): + def _parse(self, response: Response, **kwargs: Any) -> Any: if not hasattr(self, "parse_node"): raise NotConfigured( "You must define parse_node method in order to scrape this XML feed" ) response = self.adapt_response(response) + nodes: Iterable[Selector] if self.iterator == "iternodes": nodes = self._iternodes(response) elif self.iterator == "xml": + if not isinstance(response, TextResponse): + raise ValueError("Response content isn't text") selector = Selector(response, type="xml") self._register_namespaces(selector) nodes = selector.xpath(f"//{self.itertag}") elif self.iterator == "html": + if not isinstance(response, TextResponse): + raise ValueError("Response content isn't text") selector = Selector(response, type="html") self._register_namespaces(selector) nodes = selector.xpath(f"//{self.itertag}") @@ -82,12 +98,12 @@ class XMLFeedSpider(Spider): return self.parse_nodes(response, nodes) - def _iternodes(self, response): - for node in xmliter(response, self.itertag): + def _iternodes(self, response: Response) -> Iterable[Selector]: + for node in xmliter_lxml(response, self.itertag): self._register_namespaces(node) yield node - def _register_namespaces(self, selector): + def _register_namespaces(self, selector: Selector) -> None: for prefix, uri in self.namespaces: selector.register_namespace(prefix, uri) @@ -101,27 +117,29 @@ class CSVFeedSpider(Spider): and the file's headers. """ - delimiter = ( + delimiter: str | None = ( None # When this is None, python's csv module's default delimiter is used ) - quotechar = ( + quotechar: str | None = ( None # When this is None, python's csv module's default quotechar is used ) - headers = None + headers: list[str] | None = None - def process_results(self, response, results): + def process_results( + self, response: Response, results: Iterable[Any] + ) -> Iterable[Any]: """This method has the same purpose as the one in XMLFeedSpider""" return results - def adapt_response(self, response): + def adapt_response(self, response: Response) -> Response: """This method has the same purpose as the one in XMLFeedSpider""" return response - def parse_row(self, response, row): + def parse_row(self, response: Response, row: dict[str, str]) -> Any: """This method must be overridden with your custom spider functionality""" raise NotImplementedError - def parse_rows(self, response): + def parse_rows(self, response: Response) -> Any: """Receives a response and a dict (representing each row) with a key for each provided (or detected) header of the CSV file. This spider also gives the opportunity to override adapt_response and @@ -134,7 +152,7 @@ class CSVFeedSpider(Spider): ret = iterate_spider_output(self.parse_row(response, row)) yield from self.process_results(response, ret) - def _parse(self, response, **kwargs): + def _parse(self, response: Response, **kwargs: Any) -> Any: if not hasattr(self, "parse_row"): raise NotConfigured( "You must define parse_row method in order to scrape this CSV feed" diff --git a/scrapy/spiders/init.py b/scrapy/spiders/init.py index 3cb215b0f..4ec2919f7 100644 --- a/scrapy/spiders/init.py +++ b/scrapy/spiders/init.py @@ -1,21 +1,30 @@ +from __future__ import annotations + +from collections.abc import Iterable +from typing import TYPE_CHECKING, Any, cast + +from scrapy import Request from scrapy.spiders import Spider from scrapy.utils.spider import iterate_spider_output +if TYPE_CHECKING: + from scrapy.http import Response + class InitSpider(Spider): """Base Spider with initialization facilities""" - def start_requests(self): - self._postinit_reqs = super().start_requests() - return iterate_spider_output(self.init_request()) + def start_requests(self) -> Iterable[Request]: + self._postinit_reqs: Iterable[Request] = super().start_requests() + return cast(Iterable[Request], iterate_spider_output(self.init_request())) - def initialized(self, response=None): + def initialized(self, response: Response | None = None) -> Any: """This method must be set as the callback of your last initialization request. See self.init_request() docstring for more info. """ return self.__dict__.pop("_postinit_reqs") - def init_request(self): + def init_request(self) -> Any: """This function should return one initialization request, with the self.initialized method as callback. When the self.initialized method is called this spider is considered initialized. If you need to perform diff --git a/scrapy/spiders/sitemap.py b/scrapy/spiders/sitemap.py index 974665fe0..91c7e3be9 100644 --- a/scrapy/spiders/sitemap.py +++ b/scrapy/spiders/sitemap.py @@ -1,41 +1,71 @@ +from __future__ import annotations + import logging import re +from typing import TYPE_CHECKING, Any, cast -from scrapy.http import Request, XmlResponse +from scrapy.http import Request, Response, XmlResponse from scrapy.spiders import Spider +from scrapy.utils._compression import _DecompressionMaxSizeExceeded from scrapy.utils.gz import gunzip, gzip_magic_number from scrapy.utils.sitemap import Sitemap, sitemap_urls_from_robots +if TYPE_CHECKING: + from collections.abc import Iterable, Sequence + + # typing.Self requires Python 3.11 + from typing_extensions import Self + + from scrapy.crawler import Crawler + from scrapy.http.request import CallbackT + logger = logging.getLogger(__name__) class SitemapSpider(Spider): - sitemap_urls = () - sitemap_rules = [("", "parse")] - sitemap_follow = [""] - sitemap_alternate_links = False + sitemap_urls: Sequence[str] = () + sitemap_rules: Sequence[tuple[re.Pattern[str] | str, str | CallbackT]] = [ + ("", "parse") + ] + sitemap_follow: Sequence[re.Pattern[str] | str] = [""] + sitemap_alternate_links: bool = False + _max_size: int + _warn_size: int - def __init__(self, *a, **kw): + @classmethod + def from_crawler(cls, crawler: Crawler, *args: Any, **kwargs: Any) -> Self: + spider = super().from_crawler(crawler, *args, **kwargs) + spider._max_size = getattr( + spider, "download_maxsize", spider.settings.getint("DOWNLOAD_MAXSIZE") + ) + spider._warn_size = getattr( + spider, "download_warnsize", spider.settings.getint("DOWNLOAD_WARNSIZE") + ) + return spider + + def __init__(self, *a: Any, **kw: Any): super().__init__(*a, **kw) - self._cbs = [] + self._cbs: list[tuple[re.Pattern[str], CallbackT]] = [] for r, c in self.sitemap_rules: if isinstance(c, str): - c = getattr(self, c) + c = cast("CallbackT", getattr(self, c)) self._cbs.append((regex(r), c)) - self._follow = [regex(x) for x in self.sitemap_follow] + self._follow: list[re.Pattern[str]] = [regex(x) for x in self.sitemap_follow] - def start_requests(self): + def start_requests(self) -> Iterable[Request]: for url in self.sitemap_urls: yield Request(url, self._parse_sitemap) - def sitemap_filter(self, entries): + def sitemap_filter( + self, entries: Iterable[dict[str, Any]] + ) -> Iterable[dict[str, Any]]: """This method can be used to filter sitemap entries by their attributes, for example, you can filter locs with lastmod greater than a given date (see docs). """ yield from entries - def _parse_sitemap(self, response): + def _parse_sitemap(self, response: Response) -> Iterable[Request]: if response.url.endswith("/robots.txt"): for url in sitemap_urls_from_robots(response.text, base_url=response.url): yield Request(url, callback=self._parse_sitemap) @@ -63,14 +93,26 @@ class SitemapSpider(Spider): yield Request(loc, callback=c) break - def _get_sitemap_body(self, response): + def _get_sitemap_body(self, response: Response) -> bytes | None: """Return the sitemap body contained in the given response, or None if the response is not a sitemap. """ if isinstance(response, XmlResponse): return response.body if gzip_magic_number(response): - return gunzip(response.body) + uncompressed_size = len(response.body) + max_size = response.meta.get("download_maxsize", self._max_size) + warn_size = response.meta.get("download_warnsize", self._warn_size) + try: + body = gunzip(response.body, max_size=max_size) + except _DecompressionMaxSizeExceeded: + return None + if uncompressed_size < warn_size <= len(body): + logger.warning( + f"{response} body size after decompression ({len(body)} B) " + f"is larger than the download warning size ({warn_size} B)." + ) + return body # actual gzipped sitemap files are decompressed above ; # if we are here (response body is not gzipped) # and have a response for .xml.gz, @@ -82,15 +124,16 @@ class SitemapSpider(Spider): # in other word, here, we have plain XML if response.url.endswith(".xml") or response.url.endswith(".xml.gz"): return response.body + return None -def regex(x): +def regex(x: re.Pattern[str] | str) -> re.Pattern[str]: if isinstance(x, str): return re.compile(x) return x -def iterloc(it, alt=False): +def iterloc(it: Iterable[dict[str, Any]], alt: bool = False) -> Iterable[str]: for d in it: yield d["loc"] diff --git a/scrapy/squeues.py b/scrapy/squeues.py index f665ad88c..7732187fd 100644 --- a/scrapy/squeues.py +++ b/scrapy/squeues.py @@ -2,20 +2,31 @@ Scheduler queues """ +from __future__ import annotations + import marshal -import pickle -from os import PathLike +import pickle # nosec from pathlib import Path -from typing import Union +from typing import TYPE_CHECKING, Any from queuelib import queue from scrapy.utils.request import request_from_dict +if TYPE_CHECKING: + from collections.abc import Callable + from os import PathLike -def _with_mkdir(queue_class): - class DirectoriesCreated(queue_class): - def __init__(self, path: Union[str, PathLike], *args, **kwargs): + # typing.Self requires Python 3.11 + from typing_extensions import Self + + from scrapy import Request + from scrapy.crawler import Crawler + + +def _with_mkdir(queue_class: type[queue.BaseQueue]) -> type[queue.BaseQueue]: + class DirectoriesCreated(queue_class): # type: ignore[valid-type,misc] + def __init__(self, path: str | PathLike, *args: Any, **kwargs: Any): dirname = Path(path).parent if not dirname.exists(): dirname.mkdir(parents=True, exist_ok=True) @@ -24,18 +35,23 @@ def _with_mkdir(queue_class): return DirectoriesCreated -def _serializable_queue(queue_class, serialize, deserialize): - class SerializableQueue(queue_class): - def push(self, obj): +def _serializable_queue( + queue_class: type[queue.BaseQueue], + serialize: Callable[[Any], bytes], + deserialize: Callable[[bytes], Any], +) -> type[queue.BaseQueue]: + class SerializableQueue(queue_class): # type: ignore[valid-type,misc] + def push(self, obj: Any) -> None: s = serialize(obj) super().push(s) - def pop(self): + def pop(self) -> Any | None: s = super().pop() if s: return deserialize(s) + return None - def peek(self): + def peek(self) -> Any | None: """Returns the next object to be returned by :meth:`pop`, but without removing it from the queue. @@ -50,31 +66,36 @@ def _serializable_queue(queue_class, serialize, deserialize): ) from ex if s: return deserialize(s) + return None return SerializableQueue -def _scrapy_serialization_queue(queue_class): - class ScrapyRequestQueue(queue_class): - def __init__(self, crawler, key): +def _scrapy_serialization_queue( + queue_class: type[queue.BaseQueue], +) -> type[queue.BaseQueue]: + class ScrapyRequestQueue(queue_class): # type: ignore[valid-type,misc] + def __init__(self, crawler: Crawler, key: str): self.spider = crawler.spider super().__init__(key) @classmethod - def from_crawler(cls, crawler, key, *args, **kwargs): + def from_crawler( + cls, crawler: Crawler, key: str, *args: Any, **kwargs: Any + ) -> Self: return cls(crawler, key) - def push(self, request): - request = request.to_dict(spider=self.spider) - return super().push(request) + def push(self, request: Request) -> None: + request_dict = request.to_dict(spider=self.spider) + super().push(request_dict) - def pop(self): + def pop(self) -> Request | None: request = super().pop() if not request: return None return request_from_dict(request, spider=self.spider) - def peek(self): + def peek(self) -> Request | None: """Returns the next object to be returned by :meth:`pop`, but without removing it from the queue. @@ -89,13 +110,15 @@ def _scrapy_serialization_queue(queue_class): return ScrapyRequestQueue -def _scrapy_non_serialization_queue(queue_class): - class ScrapyRequestQueue(queue_class): +def _scrapy_non_serialization_queue( + queue_class: type[queue.BaseQueue], +) -> type[queue.BaseQueue]: + class ScrapyRequestQueue(queue_class): # type: ignore[valid-type,misc] @classmethod - def from_crawler(cls, crawler, *args, **kwargs): + def from_crawler(cls, crawler: Crawler, *args: Any, **kwargs: Any) -> Self: return cls() - def peek(self): + def peek(self) -> Any | None: """Returns the next object to be returned by :meth:`pop`, but without removing it from the queue. @@ -113,7 +136,7 @@ def _scrapy_non_serialization_queue(queue_class): return ScrapyRequestQueue -def _pickle_serialize(obj): +def _pickle_serialize(obj: Any) -> bytes: try: return pickle.dumps(obj, protocol=4) # Both pickle.PicklingError and AttributeError can be raised by pickle.dump(s) @@ -122,17 +145,18 @@ def _pickle_serialize(obj): raise ValueError(str(e)) from e +# queue.*Queue aren't subclasses of queue.BaseQueue _PickleFifoSerializationDiskQueue = _serializable_queue( - _with_mkdir(queue.FifoDiskQueue), _pickle_serialize, pickle.loads + _with_mkdir(queue.FifoDiskQueue), _pickle_serialize, pickle.loads # type: ignore[arg-type] ) _PickleLifoSerializationDiskQueue = _serializable_queue( - _with_mkdir(queue.LifoDiskQueue), _pickle_serialize, pickle.loads + _with_mkdir(queue.LifoDiskQueue), _pickle_serialize, pickle.loads # type: ignore[arg-type] ) _MarshalFifoSerializationDiskQueue = _serializable_queue( - _with_mkdir(queue.FifoDiskQueue), marshal.dumps, marshal.loads + _with_mkdir(queue.FifoDiskQueue), marshal.dumps, marshal.loads # type: ignore[arg-type] ) _MarshalLifoSerializationDiskQueue = _serializable_queue( - _with_mkdir(queue.LifoDiskQueue), marshal.dumps, marshal.loads + _with_mkdir(queue.LifoDiskQueue), marshal.dumps, marshal.loads # type: ignore[arg-type] ) # public queue classes @@ -140,5 +164,5 @@ PickleFifoDiskQueue = _scrapy_serialization_queue(_PickleFifoSerializationDiskQu PickleLifoDiskQueue = _scrapy_serialization_queue(_PickleLifoSerializationDiskQueue) MarshalFifoDiskQueue = _scrapy_serialization_queue(_MarshalFifoSerializationDiskQueue) MarshalLifoDiskQueue = _scrapy_serialization_queue(_MarshalLifoSerializationDiskQueue) -FifoMemoryQueue = _scrapy_non_serialization_queue(queue.FifoMemoryQueue) -LifoMemoryQueue = _scrapy_non_serialization_queue(queue.LifoMemoryQueue) +FifoMemoryQueue = _scrapy_non_serialization_queue(queue.FifoMemoryQueue) # type: ignore[arg-type] +LifoMemoryQueue = _scrapy_non_serialization_queue(queue.LifoMemoryQueue) # type: ignore[arg-type] diff --git a/scrapy/statscollectors.py b/scrapy/statscollectors.py index 15193aac5..f3dd0f8e7 100644 --- a/scrapy/statscollectors.py +++ b/scrapy/statscollectors.py @@ -1,53 +1,56 @@ """ Scrapy extension for collecting scraping stats """ + +from __future__ import annotations + import logging import pprint -from typing import TYPE_CHECKING, Any, Dict, Optional - -from scrapy import Spider +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: + from scrapy import Spider from scrapy.crawler import Crawler + logger = logging.getLogger(__name__) -StatsT = Dict[str, Any] +StatsT = dict[str, Any] class StatsCollector: - def __init__(self, crawler: "Crawler"): + def __init__(self, crawler: Crawler): self._dump: bool = crawler.settings.getbool("STATS_DUMP") self._stats: StatsT = {} def get_value( - self, key: str, default: Any = None, spider: Optional[Spider] = None + self, key: str, default: Any = None, spider: Spider | None = None ) -> Any: return self._stats.get(key, default) - def get_stats(self, spider: Optional[Spider] = None) -> StatsT: + def get_stats(self, spider: Spider | None = None) -> StatsT: return self._stats - def set_value(self, key: str, value: Any, spider: Optional[Spider] = None) -> None: + def set_value(self, key: str, value: Any, spider: Spider | None = None) -> None: self._stats[key] = value - def set_stats(self, stats: StatsT, spider: Optional[Spider] = None) -> None: + def set_stats(self, stats: StatsT, spider: Spider | None = None) -> None: self._stats = stats def inc_value( - self, key: str, count: int = 1, start: int = 0, spider: Optional[Spider] = None + self, key: str, count: int = 1, start: int = 0, spider: Spider | None = None ) -> None: d = self._stats d[key] = d.setdefault(key, start) + count - def max_value(self, key: str, value: Any, spider: Optional[Spider] = None) -> None: + def max_value(self, key: str, value: Any, spider: Spider | None = None) -> None: self._stats[key] = max(self._stats.setdefault(key, value), value) - def min_value(self, key: str, value: Any, spider: Optional[Spider] = None) -> None: + def min_value(self, key: str, value: Any, spider: Spider | None = None) -> None: self._stats[key] = min(self._stats.setdefault(key, value), value) - def clear_stats(self, spider: Optional[Spider] = None) -> None: + def clear_stats(self, spider: Spider | None = None) -> None: self._stats.clear() def open_spider(self, spider: Spider) -> None: @@ -66,9 +69,9 @@ class StatsCollector: class MemoryStatsCollector(StatsCollector): - def __init__(self, crawler: "Crawler"): + def __init__(self, crawler: Crawler): super().__init__(crawler) - self.spider_stats: Dict[str, StatsT] = {} + self.spider_stats: dict[str, StatsT] = {} def _persist_stats(self, stats: StatsT, spider: Spider) -> None: self.spider_stats[spider.name] = stats @@ -76,23 +79,23 @@ class MemoryStatsCollector(StatsCollector): class DummyStatsCollector(StatsCollector): def get_value( - self, key: str, default: Any = None, spider: Optional[Spider] = None + self, key: str, default: Any = None, spider: Spider | None = None ) -> Any: return default - def set_value(self, key: str, value: Any, spider: Optional[Spider] = None) -> None: + def set_value(self, key: str, value: Any, spider: Spider | None = None) -> None: pass - def set_stats(self, stats: StatsT, spider: Optional[Spider] = None) -> None: + def set_stats(self, stats: StatsT, spider: Spider | None = None) -> None: pass def inc_value( - self, key: str, count: int = 1, start: int = 0, spider: Optional[Spider] = None + self, key: str, count: int = 1, start: int = 0, spider: Spider | None = None ) -> None: pass - def max_value(self, key: str, value: Any, spider: Optional[Spider] = None) -> None: + def max_value(self, key: str, value: Any, spider: Spider | None = None) -> None: pass - def min_value(self, key: str, value: Any, spider: Optional[Spider] = None) -> None: + def min_value(self, key: str, value: Any, spider: Spider | None = None) -> None: pass diff --git a/scrapy/utils/_compression.py b/scrapy/utils/_compression.py new file mode 100644 index 000000000..591737b8e --- /dev/null +++ b/scrapy/utils/_compression.py @@ -0,0 +1,125 @@ +import zlib +from io import BytesIO +from warnings import warn + +from scrapy.exceptions import ScrapyDeprecationWarning + +try: + 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) + + +try: + import zstandard +except ImportError: + pass + + +_CHUNK_SIZE = 65536 # 64 KiB + + +class _DecompressionMaxSizeExceeded(ValueError): + pass + + +def _inflate(data: bytes, *, max_size: int = 0) -> bytes: + decompressor = zlib.decompressobj() + raw_decompressor = zlib.decompressobj(wbits=-15) + input_stream = BytesIO(data) + 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 + 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)." + ) + output_stream.write(output_chunk) + output_stream.seek(0) + return output_stream.read() + + +def _unbrotli(data: bytes, *, max_size: int = 0) -> bytes: + decompressor = brotli.Decompressor() + input_stream = BytesIO(data) + 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) + 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)." + ) + output_stream.write(output_chunk) + output_stream.seek(0) + return output_stream.read() + + +def _unzstd(data: bytes, *, max_size: int = 0) -> bytes: + decompressor = zstandard.ZstdDecompressor() + stream_reader = decompressor.stream_reader(BytesIO(data)) + output_stream = BytesIO() + output_chunk = b"." + decompressed_size = 0 + 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)." + ) + output_stream.write(output_chunk) + output_stream.seek(0) + return output_stream.read() diff --git a/scrapy/utils/asyncgen.py b/scrapy/utils/asyncgen.py index 0505db343..905959c25 100644 --- a/scrapy/utils/asyncgen.py +++ b/scrapy/utils/asyncgen.py @@ -1,14 +1,21 @@ -from typing import AsyncGenerator, AsyncIterable, Iterable, Union +from __future__ import annotations + +from collections.abc import AsyncGenerator, AsyncIterable, Iterable +from typing import TypeVar + +_T = TypeVar("_T") -async def collect_asyncgen(result: AsyncIterable) -> list: +async def collect_asyncgen(result: AsyncIterable[_T]) -> list[_T]: results = [] async for x in result: results.append(x) return results -async def as_async_generator(it: Union[Iterable, AsyncIterable]) -> AsyncGenerator: +async def as_async_generator( + it: Iterable[_T] | AsyncIterable[_T], +) -> AsyncGenerator[_T]: """Wraps an iterable (sync or async) into an async generator.""" if isinstance(it, AsyncIterable): async for r in it: diff --git a/scrapy/utils/benchserver.py b/scrapy/utils/benchserver.py index 38884a9f0..550516141 100644 --- a/scrapy/utils/benchserver.py +++ b/scrapy/utils/benchserver.py @@ -1,33 +1,33 @@ import random +from typing import Any from urllib.parse import urlencode from twisted.web.resource import Resource -from twisted.web.server import Site +from twisted.web.server import Request, Site class Root(Resource): isLeaf = True - def getChild(self, name, request): + def getChild(self, name: str, request: Request) -> Resource: return self - def render(self, request): + def render(self, request: Request) -> bytes: total = _getarg(request, b"total", 100, int) show = _getarg(request, b"show", 10, int) - nlist = [random.randint(1, total) for _ in range(show)] + nlist = [random.randint(1, total) for _ in range(show)] # nosec request.write(b"") + assert request.args is not None args = request.args.copy() for nl in nlist: args["n"] = nl argstr = urlencode(args, doseq=True) - request.write( - f"follow {nl}
".encode("utf8") - ) + request.write(f"follow {nl}
".encode()) request.write(b"") return b"" -def _getarg(request, name, default=None, type=str): +def _getarg(request, name: bytes, default: Any = None, type=str): return type(request.args[name][0]) if name in request.args else default @@ -38,7 +38,7 @@ if __name__ == "__main__": factory = Site(root) httpPort = reactor.listenTCP(8998, Site(root)) - def _print_listening(): + def _print_listening() -> None: httpHost = httpPort.getHost() print(f"Bench server at http://{httpHost.host}:{httpHost.port}") diff --git a/scrapy/utils/conf.py b/scrapy/utils/conf.py index 641dfa4a2..64cd31c4b 100644 --- a/scrapy/utils/conf.py +++ b/scrapy/utils/conf.py @@ -1,34 +1,29 @@ +from __future__ import annotations + import numbers import os import sys import warnings +from collections.abc import Iterable from configparser import ConfigParser from operator import itemgetter from pathlib import Path -from typing import ( - Any, - Callable, - Collection, - Dict, - Iterable, - List, - Mapping, - MutableMapping, - Optional, - Union, -) +from typing import TYPE_CHECKING, Any, Callable, cast from scrapy.exceptions import ScrapyDeprecationWarning, UsageError from scrapy.settings import BaseSettings from scrapy.utils.deprecate import update_classpath from scrapy.utils.python import without_none_values +if TYPE_CHECKING: + from collections.abc import Collection, Mapping, MutableMapping + def build_component_list( compdict: MutableMapping[Any, Any], custom: Any = None, convert: Callable[[Any], Any] = update_classpath, -) -> List[Any]: +) -> list[Any]: """Compose a component list from a { class: order } dictionary.""" def _check_components(complist: Collection[Any]) -> None: @@ -38,7 +33,7 @@ def build_component_list( "please update your settings" ) - def _map_keys(compdict: Mapping[Any, Any]) -> Union[BaseSettings, Dict[Any, Any]]: + def _map_keys(compdict: Mapping[Any, Any]) -> BaseSettings | dict[Any, Any]: if isinstance(compdict, BaseSettings): compbs = BaseSettings() for k, v in compdict.items(): @@ -83,7 +78,7 @@ def build_component_list( return [k for k, v in sorted(compdict.items(), key=itemgetter(1))] -def arglist_to_dict(arglist: List[str]) -> Dict[str, str]: +def arglist_to_dict(arglist: list[str]) -> dict[str, str]: """Convert a list of arguments like ['arg1=val1', 'arg2=val2', ...] to a dict """ @@ -91,8 +86,8 @@ def arglist_to_dict(arglist: List[str]) -> Dict[str, str]: def closest_scrapy_cfg( - path: Union[str, os.PathLike] = ".", - prevpath: Optional[Union[str, os.PathLike]] = None, + path: str | os.PathLike = ".", + prevpath: str | os.PathLike | None = None, ) -> str: """Return the path to the closest scrapy.cfg file by traversing the current directory and its parents @@ -129,7 +124,7 @@ def get_config(use_closest: bool = True) -> ConfigParser: return cfg -def get_sources(use_closest: bool = True) -> List[str]: +def get_sources(use_closest: bool = True) -> list[str]: xdg_config_home = ( os.environ.get("XDG_CONFIG_HOME") or Path("~/.config").expanduser() ) @@ -145,8 +140,8 @@ def get_sources(use_closest: bool = True) -> List[str]: def feed_complete_default_values_from_settings( - feed: Dict[str, Any], settings: BaseSettings -) -> Dict[str, Any]: + feed: dict[str, Any], settings: BaseSettings +) -> dict[str, Any]: out = feed.copy() out.setdefault("batch_item_count", settings.getint("FEED_EXPORT_BATCH_ITEM_COUNT")) out.setdefault("encoding", settings["FEED_EXPORT_ENCODING"]) @@ -163,17 +158,17 @@ def feed_complete_default_values_from_settings( def feed_process_params_from_cli( settings: BaseSettings, - output: List[str], - output_format: Optional[str] = None, - overwrite_output: Optional[List[str]] = None, -) -> Dict[str, Dict[str, Any]]: + output: list[str], + output_format: str | None = None, + overwrite_output: list[str] | None = None, +) -> dict[str, dict[str, Any]]: """ Receives feed export params (from the 'crawl' or 'runspider' commands), checks for inconsistencies in their quantities and returns a dictionary suitable to be used as the FEEDS setting. """ valid_output_formats: Iterable[str] = without_none_values( - settings.getwithbase("FEED_EXPORTERS") + cast(dict[str, str], settings.getwithbase("FEED_EXPORTERS")) ).keys() def check_valid_format(output_format: str) -> None: @@ -222,7 +217,7 @@ def feed_process_params_from_cli( "URIs are specified" ) - result: Dict[str, Dict[str, Any]] = {} + result: dict[str, dict[str, Any]] = {} for element in output: try: feed_uri, feed_format = element.rsplit(":", 1) diff --git a/scrapy/utils/console.py b/scrapy/utils/console.py index 100f040bb..aecd3fdb7 100644 --- a/scrapy/utils/console.py +++ b/scrapy/utils/console.py @@ -1,17 +1,33 @@ +from __future__ import annotations + +from collections.abc import Callable from functools import wraps +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Iterable + +EmbedFuncT = Callable[..., None] +KnownShellsT = dict[str, Callable[..., EmbedFuncT]] -def _embed_ipython_shell(namespace={}, banner=""): +def _embed_ipython_shell( + namespace: dict[str, Any] = {}, banner: str = "" +) -> EmbedFuncT: """Start an IPython Shell""" try: - from IPython.terminal.embed import InteractiveShellEmbed + from IPython.terminal.embed import InteractiveShellEmbed # noqa: T100 from IPython.terminal.ipapp import load_default_config except ImportError: - from IPython.frontend.terminal.embed import InteractiveShellEmbed - from IPython.frontend.terminal.ipapp import load_default_config + from IPython.frontend.terminal.embed import ( # type: ignore[no-redef] # noqa: T100 + InteractiveShellEmbed, + ) + from IPython.frontend.terminal.ipapp import ( # type: ignore[no-redef] + load_default_config, + ) @wraps(_embed_ipython_shell) - def wrapper(namespace=namespace, banner=""): + def wrapper(namespace: dict[str, Any] = namespace, banner: str = "") -> None: config = load_default_config() # Always use .instance() to ensure _instance propagation to all parents # this is needed for completion works well for new imports @@ -26,30 +42,36 @@ def _embed_ipython_shell(namespace={}, banner=""): return wrapper -def _embed_bpython_shell(namespace={}, banner=""): +def _embed_bpython_shell( + namespace: dict[str, Any] = {}, banner: str = "" +) -> EmbedFuncT: """Start a bpython shell""" import bpython @wraps(_embed_bpython_shell) - def wrapper(namespace=namespace, banner=""): + def wrapper(namespace: dict[str, Any] = namespace, banner: str = "") -> None: bpython.embed(locals_=namespace, banner=banner) return wrapper -def _embed_ptpython_shell(namespace={}, banner=""): +def _embed_ptpython_shell( + namespace: dict[str, Any] = {}, banner: str = "" +) -> EmbedFuncT: """Start a ptpython shell""" import ptpython.repl @wraps(_embed_ptpython_shell) - def wrapper(namespace=namespace, banner=""): + def wrapper(namespace: dict[str, Any] = namespace, banner: str = "") -> None: print(banner) ptpython.repl.embed(locals=namespace) return wrapper -def _embed_standard_shell(namespace={}, banner=""): +def _embed_standard_shell( + namespace: dict[str, Any] = {}, banner: str = "" +) -> EmbedFuncT: """Start a standard python shell""" import code @@ -63,13 +85,13 @@ def _embed_standard_shell(namespace={}, banner=""): readline.parse_and_bind("tab:complete") @wraps(_embed_standard_shell) - def wrapper(namespace=namespace, banner=""): + def wrapper(namespace: dict[str, Any] = namespace, banner: str = "") -> None: code.interact(banner=banner, local=namespace) return wrapper -DEFAULT_PYTHON_SHELLS = { +DEFAULT_PYTHON_SHELLS: KnownShellsT = { "ptpython": _embed_ptpython_shell, "ipython": _embed_ipython_shell, "bpython": _embed_bpython_shell, @@ -77,7 +99,9 @@ DEFAULT_PYTHON_SHELLS = { } -def get_shell_embed_func(shells=None, known_shells=None): +def get_shell_embed_func( + shells: Iterable[str] | None = None, known_shells: KnownShellsT | None = None +) -> Any: """Return the first acceptable shell-embed function from a given list of shell names. """ @@ -95,7 +119,11 @@ def get_shell_embed_func(shells=None, known_shells=None): continue -def start_python_console(namespace=None, banner="", shells=None): +def start_python_console( + namespace: dict[str, Any] | None = None, + banner: str = "", + shells: Iterable[str] | None = None, +) -> None: """Start Python console bound to the given namespace. Readline support and tab completion will be used on Unix, if available. """ diff --git a/scrapy/utils/curl.py b/scrapy/utils/curl.py index f5dbbd64e..bfdd4dc8a 100644 --- a/scrapy/utils/curl.py +++ b/scrapy/utils/curl.py @@ -1,14 +1,26 @@ +from __future__ import annotations + import argparse import warnings from http.cookies import SimpleCookie from shlex import split +from typing import TYPE_CHECKING, Any, NoReturn from urllib.parse import urlparse from w3lib.http import basic_auth_header +if TYPE_CHECKING: + from collections.abc import Sequence + class DataAction(argparse.Action): - def __call__(self, parser, namespace, values, option_string=None): + def __call__( + self, + parser: argparse.ArgumentParser, + namespace: argparse.Namespace, + values: str | Sequence[Any] | None, + option_string: str | None = None, + ) -> None: value = str(values) if value.startswith("$"): value = value[1:] @@ -16,7 +28,7 @@ class DataAction(argparse.Action): class CurlParser(argparse.ArgumentParser): - def error(self, message): + def error(self, message: str) -> NoReturn: error_msg = f"There was an error parsing the curl command: {message}" raise ValueError(error_msg) @@ -42,9 +54,11 @@ for argument in safe_to_ignore_arguments: curl_parser.add_argument(*argument, action="store_true") -def _parse_headers_and_cookies(parsed_args): - headers = [] - cookies = {} +def _parse_headers_and_cookies( + parsed_args: argparse.Namespace, +) -> tuple[list[tuple[str, bytes]], dict[str, str]]: + headers: list[tuple[str, bytes]] = [] + cookies: dict[str, str] = {} for header in parsed_args.headers or (): name, val = header.split(":", 1) name = name.strip() @@ -64,7 +78,7 @@ def _parse_headers_and_cookies(parsed_args): def curl_to_request_kwargs( curl_command: str, ignore_unknown_options: bool = True -) -> dict: +) -> dict[str, Any]: """Convert a cURL command syntax to Request kwargs. :param str curl_command: string containing the curl command @@ -98,7 +112,7 @@ def curl_to_request_kwargs( method = parsed_args.method or "GET" - result = {"method": method.upper(), "url": url} + result: dict[str, Any] = {"method": method.upper(), "url": url} headers, cookies = _parse_headers_and_cookies(parsed_args) diff --git a/scrapy/utils/datatypes.py b/scrapy/utils/datatypes.py index 0ba2fe4e2..98ecb2f02 100644 --- a/scrapy/utils/datatypes.py +++ b/scrapy/utils/datatypes.py @@ -10,23 +10,15 @@ from __future__ import annotations import collections import warnings import weakref +from collections import OrderedDict from collections.abc import Mapping -from typing import ( - TYPE_CHECKING, - Any, - AnyStr, - Iterable, - Optional, - OrderedDict, - Sequence, - Tuple, - TypeVar, - Union, -) +from typing import TYPE_CHECKING, Any, AnyStr, TypeVar from scrapy.exceptions import ScrapyDeprecationWarning if TYPE_CHECKING: + from collections.abc import Iterable, Sequence + # typing.Self requires Python 3.11 from typing_extensions import Self @@ -52,7 +44,7 @@ class CaselessDict(dict): def __init__( self, - seq: Union[Mapping[AnyStr, Any], Iterable[Tuple[AnyStr, Any]], None] = None, + seq: Mapping[AnyStr, Any] | Iterable[tuple[AnyStr, Any]] | None = None, ): super().__init__() if seq: @@ -92,7 +84,7 @@ class CaselessDict(dict): return dict.setdefault(self, self.normkey(key), self.normvalue(def_val)) # type: ignore[arg-type] # doesn't fully implement MutableMapping.update() - def update(self, seq: Union[Mapping[AnyStr, Any], Iterable[Tuple[AnyStr, Any]]]) -> None: # type: ignore[override] + def update(self, seq: Mapping[AnyStr, Any] | Iterable[tuple[AnyStr, Any]]) -> None: # type: ignore[override] seq = seq.items() if isinstance(seq, Mapping) else seq iseq = ((self.normkey(k), self.normvalue(v)) for k, v in seq) super().update(iseq) @@ -110,7 +102,7 @@ class CaseInsensitiveDict(collections.UserDict): as keys and allows case-insensitive lookups. """ - def __init__(self, *args, **kwargs) -> None: + def __init__(self, *args: Any, **kwargs: Any) -> None: self._keys: dict = {} super().__init__(*args, **kwargs) @@ -153,9 +145,9 @@ class LocalCache(OrderedDict[_KT, _VT]): Older items expires first. """ - def __init__(self, limit: Optional[int] = None): + def __init__(self, limit: int | None = None): super().__init__() - self.limit: Optional[int] = limit + self.limit: int | None = limit def __setitem__(self, key: _KT, value: _VT) -> None: if self.limit: @@ -176,7 +168,7 @@ class LocalWeakReferencedCache(weakref.WeakKeyDictionary): it cannot be instantiated with an initial dictionary. """ - def __init__(self, limit: Optional[int] = None): + def __init__(self, limit: int | None = None): super().__init__() self.data: LocalCache = LocalCache(limit=limit) @@ -186,7 +178,7 @@ class LocalWeakReferencedCache(weakref.WeakKeyDictionary): except TypeError: pass # key is not weak-referenceable, skip caching - def __getitem__(self, key: _KT) -> Optional[_VT]: # type: ignore[override] + def __getitem__(self, key: _KT) -> _VT | None: # type: ignore[override] try: return super().__getitem__(key) except (TypeError, KeyError): @@ -196,8 +188,8 @@ class LocalWeakReferencedCache(weakref.WeakKeyDictionary): class SequenceExclude: """Object to test if an item is NOT within some sequence.""" - def __init__(self, seq: Sequence): - self.seq: Sequence = seq + def __init__(self, seq: Sequence[Any]): + self.seq: Sequence[Any] = seq def __contains__(self, item: Any) -> bool: return item not in self.seq diff --git a/scrapy/utils/decorators.py b/scrapy/utils/decorators.py index 04186559f..0f4d0beda 100644 --- a/scrapy/utils/decorators.py +++ b/scrapy/utils/decorators.py @@ -1,21 +1,36 @@ +from __future__ import annotations + import warnings from functools import wraps -from typing import Any, Callable +from typing import TYPE_CHECKING, Any, TypeVar -from twisted.internet import defer, threads -from twisted.internet.defer import Deferred +from twisted.internet.defer import Deferred, maybeDeferred +from twisted.internet.threads import deferToThread from scrapy.exceptions import ScrapyDeprecationWarning +if TYPE_CHECKING: + from collections.abc import Callable -def deprecated(use_instead: Any = None) -> Callable: + # typing.ParamSpec requires Python 3.10 + from typing_extensions import ParamSpec + + _P = ParamSpec("_P") + + +_T = TypeVar("_T") + + +def deprecated( + use_instead: Any = None, +) -> Callable[[Callable[_P, _T]], Callable[_P, _T]]: """This is a decorator which can be used to mark functions as deprecated. It will result in a warning being emitted when the function is used.""" - def deco(func: Callable) -> Callable: + def deco(func: Callable[_P, _T]) -> Callable[_P, _T]: @wraps(func) - def wrapped(*args: Any, **kwargs: Any) -> Any: + def wrapped(*args: _P.args, **kwargs: _P.kwargs) -> Any: message = f"Call to deprecated function {func.__name__}." if use_instead: message += f" Use {use_instead} instead." @@ -30,23 +45,23 @@ def deprecated(use_instead: Any = None) -> Callable: return deco -def defers(func: Callable) -> Callable[..., Deferred]: +def defers(func: Callable[_P, _T]) -> Callable[_P, Deferred[_T]]: """Decorator to make sure a function always returns a deferred""" @wraps(func) - def wrapped(*a: Any, **kw: Any) -> Deferred: - return defer.maybeDeferred(func, *a, **kw) + def wrapped(*a: _P.args, **kw: _P.kwargs) -> Deferred[_T]: + return maybeDeferred(func, *a, **kw) return wrapped -def inthread(func: Callable) -> Callable[..., Deferred]: +def inthread(func: Callable[_P, _T]) -> Callable[_P, Deferred[_T]]: """Decorator to call a function in a thread and return a deferred with the result """ @wraps(func) - def wrapped(*a: Any, **kw: Any) -> Deferred: - return threads.deferToThread(func, *a, **kw) + def wrapped(*a: _P.args, **kw: _P.kwargs) -> Deferred[_T]: + return deferToThread(func, *a, **kw) return wrapped diff --git a/scrapy/utils/defer.py b/scrapy/utils/defer.py index bf3c5ef5b..9ca6c6a24 100644 --- a/scrapy/utils/defer.py +++ b/scrapy/utils/defer.py @@ -1,43 +1,42 @@ """ Helper functions for dealing with Twisted deferreds """ + +from __future__ import annotations + import asyncio import inspect +import warnings from asyncio import Future +from collections.abc import Awaitable, Coroutine, Iterable, Iterator from functools import wraps from types import CoroutineType -from typing import ( - Any, - AsyncGenerator, - AsyncIterable, - AsyncIterator, - Awaitable, - Callable, - Coroutine, - Dict, - Generator, - Iterable, - Iterator, - List, - Optional, - Tuple, - TypeVar, - Union, - cast, - overload, -) +from typing import TYPE_CHECKING, Any, Generic, TypeVar, Union, cast, overload from twisted.internet import defer from twisted.internet.defer import Deferred, DeferredList, ensureDeferred from twisted.internet.task import Cooperator from twisted.python import failure -from twisted.python.failure import Failure -from scrapy.exceptions import IgnoreRequest +from scrapy.exceptions import IgnoreRequest, ScrapyDeprecationWarning from scrapy.utils.reactor import _get_asyncio_event_loop, is_asyncio_reactor_installed +if TYPE_CHECKING: + from collections.abc import AsyncIterable, AsyncIterator, Callable -def defer_fail(_failure: Failure) -> Deferred: + 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") + + +def defer_fail(_failure: Failure) -> Deferred[Any]: """Same as twisted.internet.defer.fail but delay calling errback until next reactor loop @@ -46,12 +45,12 @@ def defer_fail(_failure: Failure) -> Deferred: """ from twisted.internet import reactor - d: Deferred = Deferred() + d: Deferred[Any] = Deferred() reactor.callLater(0.1, d.errback, _failure) return d -def defer_succeed(result: Any) -> Deferred: +def defer_succeed(result: _T) -> Deferred[_T]: """Same as twisted.internet.defer.succeed but delay calling callback until next reactor loop @@ -60,12 +59,12 @@ def defer_succeed(result: Any) -> Deferred: """ from twisted.internet import reactor - d: Deferred = Deferred() + d: Deferred[_T] = Deferred() reactor.callLater(0.1, d.callback, result) return d -def defer_result(result: Any) -> Deferred: +def defer_result(result: Any) -> Deferred[Any]: if isinstance(result, Deferred): return result if isinstance(result, failure.Failure): @@ -73,7 +72,31 @@ def defer_result(result: Any) -> Deferred: return defer_succeed(result) -def mustbe_deferred(f: Callable, *args: Any, **kw: Any) -> Deferred: +@overload +def mustbe_deferred( + f: Callable[_P, Deferred[_T]], *args: _P.args, **kw: _P.kwargs +) -> 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 +) -> Deferred[_T]: ... + + +def mustbe_deferred( + f: Callable[_P, Deferred[_T] | Coroutine[Deferred[Any], Any, _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 """ @@ -91,19 +114,23 @@ def mustbe_deferred(f: Callable, *args: Any, **kw: Any) -> Deferred: def parallel( - iterable: Iterable, count: int, callable: Callable, *args: Any, **named: Any -) -> Deferred: + iterable: Iterable[_T], + count: int, + callable: Callable[Concatenate[_T, _P], _T2], + *args: _P.args, + **named: _P.kwargs, +) -> Deferred[list[tuple[bool, Iterator[_T2]]]]: """Execute a callable over the objects in the given iterable, in parallel, using no more than ``count`` concurrent calls. Taken from: https://jcalderone.livejournal.com/24285.html """ coop = Cooperator() - work = (callable(elem, *args, **named) for elem in iterable) + work: Iterator[_T2] = (callable(elem, *args, **named) for elem in iterable) return DeferredList([coop.coiterate(work) for _ in range(count)]) -class _AsyncCooperatorAdapter(Iterator): +class _AsyncCooperatorAdapter(Iterator, Generic[_T]): """A class that wraps an async iterable into a normal iterator suitable for using in Cooperator.coiterate(). As it's only needed for parallel_async(), it calls the callable directly in the callback, instead of providing a more @@ -151,28 +178,30 @@ class _AsyncCooperatorAdapter(Iterator): def __init__( self, - aiterable: AsyncIterable, - callable: Callable, - *callable_args: Any, - **callable_kwargs: Any, + aiterable: AsyncIterable[_T], + callable: Callable[Concatenate[_T, _P], Deferred[Any] | None], + *callable_args: _P.args, + **callable_kwargs: _P.kwargs, ): - self.aiterator: AsyncIterator = aiterable.__aiter__() - self.callable: Callable = callable - self.callable_args: Tuple[Any, ...] = callable_args - self.callable_kwargs: Dict[str, Any] = callable_kwargs + self.aiterator: AsyncIterator[_T] = aiterable.__aiter__() + 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 - self.waiting_deferreds: List[Deferred] = [] - self.anext_deferred: Optional[Deferred] = None + self.waiting_deferreds: list[Deferred[Any]] = [] + self.anext_deferred: Deferred[_T] | None = None - def _callback(self, result: Any) -> None: + def _callback(self, result: _T) -> None: # This gets called when the result from aiterator.__anext__() is available. # It calls the callable on it and sends the result to the oldest waiting Deferred # (by chaining if the result is a Deferred too or by firing if not). self.anext_deferred = None - result = self.callable(result, *self.callable_args, **self.callable_kwargs) + callable_result = self.callable( + result, *self.callable_args, **self.callable_kwargs + ) d = self.waiting_deferreds.pop(0) - if isinstance(result, Deferred): - result.chainDeferred(d) + if isinstance(callable_result, Deferred): + callable_result.chainDeferred(d) else: d.callback(None) if self.waiting_deferreds: @@ -193,12 +222,12 @@ class _AsyncCooperatorAdapter(Iterator): self.anext_deferred = deferred_from_coro(self.aiterator.__anext__()) self.anext_deferred.addCallbacks(self._callback, self._errback) - def __next__(self) -> Deferred: + def __next__(self) -> Deferred[Any]: # This puts a new Deferred into self.waiting_deferreds and returns it. # It also calls __anext__() if needed. if self.finished: raise StopIteration - d: Deferred = Deferred() + d: Deferred[Any] = Deferred() self.waiting_deferreds.append(d) if not self.anext_deferred: self._call_anext() @@ -206,24 +235,31 @@ class _AsyncCooperatorAdapter(Iterator): def parallel_async( - async_iterable: AsyncIterable, + async_iterable: AsyncIterable[_T], count: int, - callable: Callable, - *args: Any, - **named: Any, -) -> Deferred: - """Like parallel but for async iterators""" + callable: Callable[Concatenate[_T, _P], Deferred[Any] | None], + *args: _P.args, + **named: _P.kwargs, +) -> Deferred[list[tuple[bool, Iterator[Deferred[Any]]]]]: + """Like ``parallel`` but for async iterators""" coop = Cooperator() - work = _AsyncCooperatorAdapter(async_iterable, callable, *args, **named) - dl: Deferred = DeferredList([coop.coiterate(work) for _ in range(count)]) + work: Iterator[Deferred[Any]] = _AsyncCooperatorAdapter( + async_iterable, callable, *args, **named + ) + dl: Deferred[list[tuple[bool, Iterator[Deferred[Any]]]]] = DeferredList( + [coop.coiterate(work) for _ in range(count)] + ) return dl def process_chain( - callbacks: Iterable[Callable], input: Any, *a: Any, **kw: Any -) -> Deferred: + callbacks: Iterable[Callable[Concatenate[_T, _P], _T]], + input: _T, + *a: _P.args, + **kw: _P.kwargs, +) -> Deferred[_T]: """Return a Deferred built by chaining the given callbacks""" - d: Deferred = Deferred() + d: Deferred[_T] = Deferred() for x in callbacks: d.addCallback(x, *a, **kw) d.callback(input) @@ -231,23 +267,23 @@ def process_chain( def process_chain_both( - callbacks: Iterable[Callable], - errbacks: Iterable[Callable], + callbacks: Iterable[Callable[Concatenate[_T, _P], Any]], + errbacks: Iterable[Callable[Concatenate[Failure, _P], Any]], input: Any, - *a: Any, - **kw: 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.addCallbacks( - callback=cb, - errback=eb, - callbackArgs=a, - callbackKeywords=kw, - errbackArgs=a, - errbackKeywords=kw, - ) + d.addCallback(cb, *a, **kw) + d.addErrback(eb, *a, **kw) if isinstance(input, failure.Failure): d.errback(input) else: @@ -256,20 +292,33 @@ def process_chain_both( def process_parallel( - callbacks: Iterable[Callable], input: Any, *a: Any, **kw: Any -) -> Deferred: + callbacks: Iterable[Callable[Concatenate[_T, _P], _T2]], + input: _T, + *a: _P.args, + **kw: _P.kwargs, +) -> Deferred[list[_T2]]: """Return a Deferred with the output of all successful calls to the given callbacks """ dfds = [defer.succeed(input).addCallback(x, *a, **kw) for x in callbacks] - d: Deferred = DeferredList(dfds, fireOnOneErrback=True, consumeErrors=True) - d.addCallbacks(lambda r: [x[1] for x in r], lambda f: f.value.subFailure) - return d + d: Deferred[list[tuple[bool, _T2]]] = DeferredList( + dfds, fireOnOneErrback=True, consumeErrors=True + ) + d2: Deferred[list[_T2]] = 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 iter_errback( - iterable: Iterable, errback: Callable, *a: Any, **kw: Any -) -> Generator: + iterable: Iterable[_T], + errback: Callable[Concatenate[Failure, _P], Any], + *a: _P.args, + **kw: _P.kwargs, +) -> Iterable[_T]: """Wraps an iterable calling an errback if an error is caught while iterating it. """ @@ -284,8 +333,11 @@ def iter_errback( async def aiter_errback( - aiterable: AsyncIterable, errback: Callable, *a: Any, **kw: Any -) -> AsyncGenerator: + aiterable: AsyncIterable[_T], + errback: Callable[Concatenate[Failure, _P], Any], + *a: _P.args, + **kw: _P.kwargs, +) -> AsyncIterable[_T]: """Wraps an async iterable calling an errback if an error is caught while iterating it. Similar to scrapy.utils.defer.iter_errback() """ @@ -300,20 +352,17 @@ async def aiter_errback( _CT = TypeVar("_CT", bound=Union[Awaitable, CoroutineType, Future]) -_T = TypeVar("_T") @overload -def deferred_from_coro(o: _CT) -> Deferred: - ... +def deferred_from_coro(o: _CT) -> Deferred: ... @overload -def deferred_from_coro(o: _T) -> _T: - ... +def deferred_from_coro(o: _T) -> _T: ... -def deferred_from_coro(o: _T) -> Union[Deferred, _T]: +def deferred_from_coro(o: _T) -> Deferred | _T: """Converts a coroutine into a Deferred, or returns the object as is if it isn't a coroutine""" if isinstance(o, Deferred): return o @@ -328,7 +377,9 @@ def deferred_from_coro(o: _T) -> Union[Deferred, _T]: return o -def deferred_f_from_coro_f(coro_f: Callable[..., Coroutine]) -> Callable: +def deferred_f_from_coro_f( + coro_f: Callable[_P, Coroutine[Any, Any, _T]] +) -> Callable[_P, Deferred[_T]]: """Converts 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. @@ -336,17 +387,19 @@ def deferred_f_from_coro_f(coro_f: Callable[..., Coroutine]) -> Callable: """ @wraps(coro_f) - def f(*coro_args: Any, **coro_kwargs: Any) -> Any: + def f(*coro_args: _P.args, **coro_kwargs: _P.kwargs) -> Any: return deferred_from_coro(coro_f(*coro_args, **coro_kwargs)) return f -def maybeDeferred_coro(f: Callable, *args: Any, **kw: Any) -> Deferred: +def maybeDeferred_coro( + f: Callable[_P, Any], *args: _P.args, **kw: _P.kwargs +) -> Deferred[Any]: """Copy of defer.maybeDeferred that also converts coroutines to Deferreds.""" try: result = f(*args, **kw) - except: # noqa: E722 + except: # noqa: E722,B001 return defer.fail(failure.Failure(captureVars=Deferred.debug)) if isinstance(result, Deferred): @@ -358,7 +411,7 @@ def maybeDeferred_coro(f: Callable, *args: Any, **kw: Any) -> Deferred: return defer.succeed(result) -def deferred_to_future(d: Deferred) -> Future: +def deferred_to_future(d: Deferred[_T]) -> Future[_T]: """ .. versionadded:: 2.6.0 @@ -380,7 +433,7 @@ def deferred_to_future(d: Deferred) -> Future: return d.asFuture(_get_asyncio_event_loop()) -def maybe_deferred_to_future(d: Deferred) -> Union[Deferred, Future]: +def maybe_deferred_to_future(d: Deferred[_T]) -> Deferred[_T] | Future[_T]: """ .. versionadded:: 2.6.0 diff --git a/scrapy/utils/deprecate.py b/scrapy/utils/deprecate.py index ea577c44a..32430cd6c 100644 --- a/scrapy/utils/deprecate.py +++ b/scrapy/utils/deprecate.py @@ -1,8 +1,10 @@ """Some helpers for deprecation messages""" +from __future__ import annotations + import inspect import warnings -from typing import Any, Dict, List, Optional, Tuple, Type, overload +from typing import Any, overload from scrapy.exceptions import ScrapyDeprecationWarning @@ -20,11 +22,11 @@ def attribute(obj: Any, oldattr: str, newattr: str, version: str = "0.12") -> No def create_deprecated_class( name: str, new_class: type, - clsdict: Optional[Dict[str, Any]] = None, - warn_category: Type[Warning] = ScrapyDeprecationWarning, + clsdict: dict[str, Any] | None = None, + warn_category: type[Warning] = ScrapyDeprecationWarning, warn_once: bool = True, - old_class_path: Optional[str] = None, - new_class_path: Optional[str] = None, + old_class_path: str | None = None, + new_class_path: str | None = None, subclass_warn_message: str = "{cls} inherits from deprecated class {old}, please inherit from {new}.", instance_warn_message: str = "{cls} is deprecated, instantiate {new} instead.", ) -> type: @@ -55,18 +57,18 @@ def create_deprecated_class( # https://github.com/python/mypy/issues/4177 class DeprecatedClass(new_class.__class__): # type: ignore[misc, name-defined] - deprecated_class: Optional[type] = None + deprecated_class: type | None = None warned_on_subclass: bool = False def __new__( - metacls, name: str, bases: Tuple[type, ...], clsdict_: Dict[str, Any] + metacls, name: str, bases: tuple[type, ...], clsdict_: dict[str, Any] ) -> type: cls = super().__new__(metacls, name, bases, clsdict_) if metacls.deprecated_class is None: metacls.deprecated_class = cls return cls - def __init__(cls, name: str, bases: Tuple[type, ...], clsdict_: Dict[str, Any]): + def __init__(cls, name: str, bases: tuple[type, ...], clsdict_: dict[str, Any]): meta = cls.__class__ old = meta.deprecated_class if old in bases and not (warn_once and meta.warned_on_subclass): @@ -128,23 +130,21 @@ def create_deprecated_class( return deprecated_cls -def _clspath(cls: type, forced: Optional[str] = None) -> str: +def _clspath(cls: type, forced: str | None = None) -> str: if forced is not None: return forced return f"{cls.__module__}.{cls.__name__}" -DEPRECATION_RULES: List[Tuple[str, str]] = [] +DEPRECATION_RULES: list[tuple[str, str]] = [] @overload -def update_classpath(path: str) -> str: - ... +def update_classpath(path: str) -> str: ... @overload -def update_classpath(path: Any) -> Any: - ... +def update_classpath(path: Any) -> Any: ... def update_classpath(path: Any) -> Any: diff --git a/scrapy/utils/engine.py b/scrapy/utils/engine.py index a5f2a8c6e..1430ed8d6 100644 --- a/scrapy/utils/engine.py +++ b/scrapy/utils/engine.py @@ -1,14 +1,16 @@ """Some debugging functions for working with the Scrapy engine""" +from __future__ import annotations + # used in global tests code from time import time # noqa: F401 -from typing import TYPE_CHECKING, Any, List, Tuple +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from scrapy.core.engine import ExecutionEngine -def get_engine_status(engine: "ExecutionEngine") -> List[Tuple[str, Any]]: +def get_engine_status(engine: ExecutionEngine) -> list[tuple[str, Any]]: """Return a report of the current engine status""" tests = [ "time()-engine.start_time", @@ -27,17 +29,17 @@ def get_engine_status(engine: "ExecutionEngine") -> List[Tuple[str, Any]]: "engine.scraper.slot.needs_backout()", ] - checks: List[Tuple[str, Any]] = [] + checks: list[tuple[str, Any]] = [] for test in tests: try: - checks += [(test, eval(test))] + checks += [(test, eval(test))] # nosec except Exception as e: checks += [(test, f"{type(e).__name__} (exception)")] return checks -def format_engine_status(engine: "ExecutionEngine") -> str: +def format_engine_status(engine: ExecutionEngine) -> str: checks = get_engine_status(engine) s = "Execution engine status\n\n" for test, result in checks: @@ -47,5 +49,5 @@ def format_engine_status(engine: "ExecutionEngine") -> str: return s -def print_engine_status(engine: "ExecutionEngine") -> None: +def print_engine_status(engine: ExecutionEngine) -> None: print(format_engine_status(engine)) diff --git a/scrapy/utils/ftp.py b/scrapy/utils/ftp.py index c77681a53..152f3374e 100644 --- a/scrapy/utils/ftp.py +++ b/scrapy/utils/ftp.py @@ -21,7 +21,7 @@ def ftp_makedirs_cwd(ftp: FTP, path: str, first_call: bool = True) -> None: def ftp_store_file( *, path: str, - file: IO, + file: IO[bytes], host: str, port: int, username: str, diff --git a/scrapy/utils/gz.py b/scrapy/utils/gz.py index c7f74030e..85324361c 100644 --- a/scrapy/utils/gz.py +++ b/scrapy/utils/gz.py @@ -1,31 +1,45 @@ +from __future__ import annotations + import struct from gzip import GzipFile from io import BytesIO -from typing import List +from typing import TYPE_CHECKING -from scrapy.http import Response +from ._compression import _CHUNK_SIZE, _DecompressionMaxSizeExceeded + +if TYPE_CHECKING: + from scrapy.http import Response -def gunzip(data: bytes) -> bytes: +def gunzip(data: bytes, *, max_size: int = 0) -> bytes: """Gunzip the given data and return as much data as possible. This is resilient to CRC checksum errors. """ f = GzipFile(fileobj=BytesIO(data)) - output_list: List[bytes] = [] + output_stream = BytesIO() chunk = b"." + decompressed_size = 0 while chunk: try: - chunk = f.read1(8196) - output_list.append(chunk) + chunk = f.read1(_CHUNK_SIZE) except (OSError, EOFError, struct.error): # complete only if there is some data, otherwise re-raise # see issue 87 about catching struct.error - # some pages are quite small so output_list is empty - if output_list: + # some pages are quite small so output_stream is empty + if output_stream.getbuffer().nbytes > 0: break raise - return b"".join(output_list) + 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)." + ) + output_stream.write(chunk) + output_stream.seek(0) + return output_stream.read() def gzip_magic_number(response: Response) -> bool: diff --git a/scrapy/utils/httpobj.py b/scrapy/utils/httpobj.py index d502e8910..58b4539bf 100644 --- a/scrapy/utils/httpobj.py +++ b/scrapy/utils/httpobj.py @@ -1,17 +1,21 @@ """Helper functions for scrapy.http objects (Request, Response)""" -from typing import Union +from __future__ import annotations + +from typing import TYPE_CHECKING from urllib.parse import ParseResult, urlparse from weakref import WeakKeyDictionary -from scrapy.http import Request, Response +if TYPE_CHECKING: + from scrapy.http import Request, Response -_urlparse_cache: "WeakKeyDictionary[Union[Request, Response], ParseResult]" = ( + +_urlparse_cache: WeakKeyDictionary[Request | Response, ParseResult] = ( WeakKeyDictionary() ) -def urlparse_cached(request_or_response: Union[Request, Response]) -> ParseResult: +def urlparse_cached(request_or_response: Request | Response) -> ParseResult: """Return urlparse.urlparse caching the result, where the argument can be a Request or Response object """ diff --git a/scrapy/utils/iterators.py b/scrapy/utils/iterators.py index 55362efdf..ba58d939c 100644 --- a/scrapy/utils/iterators.py +++ b/scrapy/utils/iterators.py @@ -1,35 +1,26 @@ +from __future__ import annotations + import csv import logging import re from io import StringIO -from typing import ( - TYPE_CHECKING, - Any, - Callable, - Dict, - Generator, - Iterable, - List, - Literal, - Optional, - Union, - cast, - overload, -) +from typing import TYPE_CHECKING, Any, Literal, cast, overload +from warnings import warn +from lxml import etree # nosec + +from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.http import Response, TextResponse from scrapy.selector import Selector -from scrapy.utils.python import re_rsearch, to_unicode +from scrapy.utils.python import re_rsearch if TYPE_CHECKING: - from lxml._types import SupportsReadClose + from collections.abc import Callable, Iterator logger = logging.getLogger(__name__) -def xmliter( - obj: Union[Response, str, bytes], nodename: str -) -> Generator[Selector, Any, None]: +def xmliter(obj: Response | str | bytes, nodename: str) -> Iterator[Selector]: """Return a iterator of Selector's over all nodes of a XML document, given the name of the node to iterate. Useful for parsing XML feeds. @@ -38,6 +29,16 @@ def xmliter( - a unicode string - a string encoded as utf-8 """ + warn( + ( + "xmliter is deprecated and its use strongly discouraged because " + "it is vulnerable to ReDoS attacks. Use xmliter_lxml instead. See " + "https://github.com/scrapy/scrapy/security/advisories/GHSA-cc65-xxvf-f7r9" + ), + ScrapyDeprecationWarning, + stacklevel=2, + ) + nodename_patt = re.escape(nodename) DOCUMENT_HEADER_RE = re.compile(r"<\?xml[^>]+>\s*", re.S) @@ -52,7 +53,7 @@ def xmliter( ) header_end_idx = re_rsearch(HEADER_END_RE, text) header_end = text[header_end_idx[1] :].strip() if header_end_idx else "" - namespaces: Dict[str, str] = {} + namespaces: dict[str, str] = {} if header_end: for tagname in reversed(re.findall(END_TAG_RE, header_end)): assert header_end_idx @@ -76,20 +77,40 @@ def xmliter( def xmliter_lxml( - obj: Union[Response, str, bytes], + obj: Response | str | bytes, nodename: str, - namespace: Optional[str] = None, + namespace: str | None = None, prefix: str = "x", -) -> Generator[Selector, Any, None]: - from lxml import etree - +) -> Iterator[Selector]: reader = _StreamReader(obj) tag = f"{{{namespace}}}{nodename}" if namespace else nodename iterable = etree.iterparse( - cast("SupportsReadClose[bytes]", reader), tag=tag, encoding=reader.encoding + reader, + encoding=reader.encoding, + events=("end", "start-ns"), + resolve_entities=False, + huge_tree=True, ) selxpath = "//" + (f"{prefix}:{nodename}" if namespace else nodename) - for _, node in iterable: + needs_namespace_resolution = not namespace and ":" in nodename + if needs_namespace_resolution: + prefix, nodename = nodename.split(":", maxsplit=1) + for event, data in iterable: + if event == "start-ns": + assert isinstance(data, tuple) + if needs_namespace_resolution: + _prefix, _namespace = data + if _prefix != prefix: + continue + namespace = _namespace + needs_namespace_resolution = False + selxpath = f"//{prefix}:{nodename}" + tag = f"{{{namespace}}}{nodename}" + continue + assert isinstance(data, etree._Element) + node = data + if node.tag != tag: + continue nodetext = etree.tostring(node, encoding="unicode") node.clear() xs = Selector(text=nodetext, type="xml") @@ -99,9 +120,9 @@ def xmliter_lxml( class _StreamReader: - def __init__(self, obj: Union[Response, str, bytes]): + def __init__(self, obj: Response | str | bytes): self._ptr: int = 0 - self._text: Union[str, bytes] + self._text: str | bytes if isinstance(obj, TextResponse): self._text, self.encoding = obj.body, obj.encoding elif isinstance(obj, Response): @@ -133,12 +154,12 @@ class _StreamReader: def csviter( - obj: Union[Response, str, bytes], - delimiter: Optional[str] = None, - headers: Optional[List[str]] = None, - encoding: Optional[str] = None, - quotechar: Optional[str] = None, -) -> Generator[Dict[str, str], Any, None]: + obj: Response | str | bytes, + delimiter: str | None = None, + headers: list[str] | None = None, + encoding: str | None = None, + quotechar: str | None = None, +) -> Iterator[dict[str, str]]: """Returns an iterator of dictionaries from the given csv object obj can be: @@ -154,14 +175,17 @@ def csviter( quotechar is the character used to enclosure fields on the given obj. """ - encoding = obj.encoding if isinstance(obj, TextResponse) else encoding or "utf-8" - - def row_to_unicode(row_: Iterable) -> List[str]: - return [to_unicode(field, encoding) for field in row_] + if encoding is not None: + warn( + "The encoding argument of csviter() is ignored and will be removed" + " in a future Scrapy version.", + category=ScrapyDeprecationWarning, + stacklevel=2, + ) lines = StringIO(_body_or_str(obj, unicode=True)) - kwargs: Dict[str, Any] = {} + kwargs: dict[str, Any] = {} if delimiter: kwargs["delimiter"] = delimiter if quotechar: @@ -170,13 +194,11 @@ def csviter( if not headers: try: - row = next(csv_r) + headers = next(csv_r) except StopIteration: return - headers = row_to_unicode(row) for row in csv_r: - row = row_to_unicode(row) if len(row) != len(headers): logger.warning( "ignoring row %(csvlnum)d (length: %(csvrow)d, " @@ -192,23 +214,18 @@ def csviter( @overload -def _body_or_str(obj: Union[Response, str, bytes]) -> str: - ... +def _body_or_str(obj: Response | str | bytes) -> str: ... @overload -def _body_or_str(obj: Union[Response, str, bytes], unicode: Literal[True]) -> str: - ... +def _body_or_str(obj: Response | str | bytes, unicode: Literal[True]) -> str: ... @overload -def _body_or_str(obj: Union[Response, str, bytes], unicode: Literal[False]) -> bytes: - ... +def _body_or_str(obj: Response | str | bytes, unicode: Literal[False]) -> bytes: ... -def _body_or_str( - obj: Union[Response, str, bytes], unicode: bool = True -) -> Union[str, bytes]: +def _body_or_str(obj: Response | str | bytes, unicode: bool = True) -> str | bytes: expected_types = (Response, str, bytes) if not isinstance(obj, expected_types): expected_types_str = " or ".join(t.__name__ for t in expected_types) diff --git a/scrapy/utils/job.py b/scrapy/utils/job.py index e230e4235..37e6aeb51 100644 --- a/scrapy/utils/job.py +++ b/scrapy/utils/job.py @@ -1,11 +1,14 @@ +from __future__ import annotations + from pathlib import Path -from typing import Optional +from typing import TYPE_CHECKING -from scrapy.settings import BaseSettings +if TYPE_CHECKING: + from scrapy.settings import BaseSettings -def job_dir(settings: BaseSettings) -> Optional[str]: - path: Optional[str] = settings["JOBDIR"] +def job_dir(settings: BaseSettings) -> str | None: + path: str | None = settings["JOBDIR"] if not path: return None if not Path(path).exists(): diff --git a/scrapy/utils/log.py b/scrapy/utils/log.py index 2a38f151a..c3808426a 100644 --- a/scrapy/utils/log.py +++ b/scrapy/utils/log.py @@ -2,26 +2,30 @@ from __future__ import annotations import logging import sys +from collections.abc import MutableMapping from logging.config import dictConfig from types import TracebackType -from typing import TYPE_CHECKING, Any, List, Optional, Tuple, Type, Union, cast +from typing import TYPE_CHECKING, Any, Optional, cast from twisted.python import log as twisted_log from twisted.python.failure import Failure import scrapy -from scrapy.settings import Settings +from scrapy.settings import Settings, _SettingsKeyT from scrapy.utils.versions import scrapy_components_versions if TYPE_CHECKING: + from scrapy.crawler import Crawler + from scrapy.logformatter import LogFormatterResult + logger = logging.getLogger(__name__) def failure_to_exc_info( failure: Failure, -) -> Optional[Tuple[Type[BaseException], BaseException, Optional[TracebackType]]]: +) -> tuple[type[BaseException], BaseException, TracebackType | None] | None: """Extract exc_info from Failure instances""" if isinstance(failure, Failure): assert failure.type @@ -35,7 +39,7 @@ def failure_to_exc_info( class TopLevelFormatter(logging.Filter): - """Keep only top level loggers's name (direct children from root) from + """Keep only top level loggers' name (direct children from root) from records. This filter will replace Scrapy loggers' names with 'scrapy'. This mimics @@ -46,8 +50,8 @@ class TopLevelFormatter(logging.Filter): ``loggers`` list where it should act. """ - def __init__(self, loggers: Optional[List[str]] = None): - self.loggers: List[str] = loggers or [] + def __init__(self, loggers: list[str] | None = None): + self.loggers: list[str] = loggers or [] def filter(self, record: logging.LogRecord) -> bool: if any(record.name.startswith(logger + ".") for logger in self.loggers): @@ -76,7 +80,8 @@ DEFAULT_LOGGING = { def configure_logging( - settings: Union[Settings, dict, None] = None, install_root_handler: bool = True + settings: Settings | dict[_SettingsKeyT, Any] | None = None, + install_root_handler: bool = True, ) -> None: """ Initialize logging defaults for Scrapy. @@ -114,13 +119,13 @@ def configure_logging( settings = Settings(settings) if settings.getbool("LOG_STDOUT"): - sys.stdout = StreamLogger(logging.getLogger("stdout")) # type: ignore[assignment] + sys.stdout = StreamLogger(logging.getLogger("stdout")) if install_root_handler: install_scrapy_root_handler(settings) -_scrapy_root_handler: Optional[logging.Handler] = None +_scrapy_root_handler: logging.Handler | None = None def install_scrapy_root_handler(settings: Settings) -> None: @@ -136,7 +141,7 @@ def install_scrapy_root_handler(settings: Settings) -> None: logging.root.addHandler(_scrapy_root_handler) -def get_scrapy_root_handler() -> Optional[logging.Handler]: +def get_scrapy_root_handler() -> logging.Handler | None: return _scrapy_root_handler @@ -224,7 +229,9 @@ class LogCounterHandler(logging.Handler): self.crawler.stats.inc_value(sname) -def logformatter_adapter(logkws: dict) -> Tuple[int, str, dict]: +def logformatter_adapter( + logkws: LogFormatterResult, +) -> tuple[int, str, dict[str, Any] | tuple[Any, ...]]: """ Helper that takes the dictionary output from the methods in LogFormatter and adapts it into a tuple of positional arguments for logger.log calls, @@ -235,6 +242,19 @@ def logformatter_adapter(logkws: dict) -> Tuple[int, str, dict]: 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 = 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) + + +class SpiderLoggerAdapter(logging.LoggerAdapter): + def process( + self, msg: str, kwargs: MutableMapping[str, Any] + ) -> tuple[str, MutableMapping[str, Any]]: + """Method that augments logging with additional 'extra' data""" + if isinstance(kwargs.get("extra"), MutableMapping): + kwargs["extra"].update(self.extra) + else: + kwargs["extra"] = self.extra + + return msg, kwargs diff --git a/scrapy/utils/misc.py b/scrapy/utils/misc.py index b38190cb3..1ab30f097 100644 --- a/scrapy/utils/misc.py +++ b/scrapy/utils/misc.py @@ -1,4 +1,7 @@ """Helper functions which don't fit anywhere else""" + +from __future__ import annotations + import ast import hashlib import inspect @@ -6,34 +9,28 @@ 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 from pkgutil import iter_modules -from types import ModuleType -from typing import ( - IO, - TYPE_CHECKING, - Any, - Callable, - Deque, - Generator, - Iterable, - List, - Optional, - Union, - cast, -) +from typing import IO, TYPE_CHECKING, Any, TypeVar, cast from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.item import Item from scrapy.utils.datatypes import LocalWeakReferencedCache if TYPE_CHECKING: + from collections.abc import Callable, Iterator + from types import ModuleType + from scrapy import Spider + from scrapy.crawler import Crawler + from scrapy.settings import BaseSettings _ITERABLE_SINGLE_VALUES = dict, Item, str, bytes +T = TypeVar("T") def arg_to_iter(arg: Any) -> Iterable[Any]: @@ -49,7 +46,7 @@ def arg_to_iter(arg: Any) -> Iterable[Any]: return [arg] -def load_object(path: Union[str, Callable]) -> Any: +def load_object(path: str | Callable[..., Any]) -> Any: """Load an object given its absolute object path, and return it. The object can be the import path of a class, function, variable or an @@ -82,7 +79,7 @@ def load_object(path: Union[str, Callable]) -> Any: return obj -def walk_modules(path: str) -> List[ModuleType]: +def walk_modules(path: str) -> list[ModuleType]: """Loads a module and all its submodules from the given module path and returns them. If *any* module throws an exception while importing, that exception is thrown back. @@ -90,7 +87,7 @@ def walk_modules(path: str) -> List[ModuleType]: For example: walk_modules('scrapy.utils') """ - mods: List[ModuleType] = [] + mods: list[ModuleType] = [] mod = import_module(path) mods.append(mod) if hasattr(mod, "__path__"): @@ -104,7 +101,7 @@ def walk_modules(path: str) -> List[ModuleType]: return mods -def md5sum(file: IO) -> str: +def md5sum(file: IO[bytes]) -> str: """Calculate the md5 checksum of a file-like object without reading its whole content in memory. @@ -112,7 +109,15 @@ def md5sum(file: IO) -> str: >>> md5sum(BytesIO(b'file content to hash')) '784406af91dd5a54fbb9c84c2236595a' """ - m = hashlib.md5() + warnings.warn( + ( + "The scrapy.utils.misc.md5sum function is deprecated, and will be " + "removed in a future version of Scrapy." + ), + ScrapyDeprecationWarning, + stacklevel=2, + ) + m = hashlib.md5() # nosec while True: d = file.read(8096) if not d: @@ -121,7 +126,7 @@ def md5sum(file: IO) -> str: return m.hexdigest() -def rel_has_nofollow(rel: Optional[str]) -> bool: +def rel_has_nofollow(rel: str | None) -> bool: """Return True if link rel attribute has nofollow type""" return rel is not None and "nofollow" in rel.replace(",", " ").split() @@ -168,7 +173,9 @@ def create_instance(objcls, settings, crawler, *args, **kwargs): return instance -def build_from_crawler(objcls, crawler, /, *args, **kwargs): +def build_from_crawler( + objcls: type[T], crawler: Crawler, /, *args: Any, **kwargs: Any +) -> T: """Construct a class instance using its ``from_crawler`` constructor. ``*args`` and ``**kwargs`` are forwarded to the constructor. @@ -176,20 +183,22 @@ def build_from_crawler(objcls, crawler, /, *args, **kwargs): Raises ``TypeError`` if the resulting instance is ``None``. """ if hasattr(objcls, "from_crawler"): - instance = objcls.from_crawler(crawler, *args, **kwargs) + instance = objcls.from_crawler(crawler, *args, **kwargs) # type: ignore[attr-defined] method_name = "from_crawler" elif hasattr(objcls, "from_settings"): - instance = objcls.from_settings(crawler.settings, *args, **kwargs) + 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 instance + return cast(T, instance) -def build_from_settings(objcls, settings, /, *args, **kwargs): +def build_from_settings( + objcls: type[T], settings: BaseSettings, /, *args: Any, **kwargs: Any +) -> T: """Construct a class instance using its ``from_settings`` constructor. ``*args`` and ``**kwargs`` are forwarded to the constructor. @@ -197,18 +206,18 @@ def build_from_settings(objcls, settings, /, *args, **kwargs): Raises ``TypeError`` if the resulting instance is ``None``. """ if hasattr(objcls, "from_settings"): - instance = objcls.from_settings(settings, *args, **kwargs) + instance = objcls.from_settings(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 instance + return cast(T, instance) @contextmanager -def set_environ(**kwargs: str) -> Generator[None, Any, None]: +def set_environ(**kwargs: str) -> Iterator[None]: """Temporarily set environment variables inside the context manager and fully restore previous environment afterwards """ @@ -225,11 +234,11 @@ def set_environ(**kwargs: str) -> Generator[None, Any, None]: os.environ[k] = v -def walk_callable(node: ast.AST) -> Generator[ast.AST, Any, None]: +def walk_callable(node: ast.AST) -> Iterable[ast.AST]: """Similar to ``ast.walk``, but walks only function body and skips nested functions defined within the node. """ - todo: Deque[ast.AST] = deque([node]) + todo: deque[ast.AST] = deque([node]) walked_func_def = False while todo: node = todo.popleft() @@ -244,7 +253,7 @@ def walk_callable(node: ast.AST) -> Generator[ast.AST, Any, None]: _generator_callbacks_cache = LocalWeakReferencedCache(limit=128) -def is_generator_with_return_value(callable: Callable) -> bool: +def is_generator_with_return_value(callable: Callable[..., Any]) -> bool: """ Returns True if a callable is a generator function which includes a 'return' statement with a value different than None, False otherwise @@ -281,7 +290,9 @@ def is_generator_with_return_value(callable: Callable) -> bool: return bool(_generator_callbacks_cache[callable]) -def warn_on_generator_with_return_value(spider: "Spider", callable: Callable) -> None: +def warn_on_generator_with_return_value( + spider: Spider, callable: Callable[..., Any] +) -> None: """ Logs a warning if a callable is a generator function and includes a 'return' statement with a value different than None diff --git a/scrapy/utils/ossignal.py b/scrapy/utils/ossignal.py index db9a71273..ad758b783 100644 --- a/scrapy/utils/ossignal.py +++ b/scrapy/utils/ossignal.py @@ -1,13 +1,16 @@ +from __future__ import annotations + import signal +from collections.abc import Callable from types import FrameType -from typing import Any, Callable, Dict, Optional, Union +from typing import Any, Optional, Union # copy of _HANDLER from typeshed/stdlib/signal.pyi SignalHandlerT = Union[ Callable[[int, Optional[FrameType]], Any], int, signal.Handlers, None ] -signal_names: Dict[int, str] = {} +signal_names: dict[int, str] = {} for signame in dir(signal): if signame.startswith("SIG") and not signame.startswith("SIG_"): signum = getattr(signal, signame) @@ -24,7 +27,11 @@ def install_shutdown_handlers( (e.g. Pdb) """ signal.signal(signal.SIGTERM, function) - if signal.getsignal(signal.SIGINT) == signal.default_int_handler or override_sigint: + if ( + signal.getsignal(signal.SIGINT) # pylint: disable=comparison-with-callable + == signal.default_int_handler + or override_sigint + ): signal.signal(signal.SIGINT, function) # Catch Ctrl-Break in windows if hasattr(signal, "SIGBREAK"): diff --git a/scrapy/utils/project.py b/scrapy/utils/project.py index de3c8eaf9..0139720b7 100644 --- a/scrapy/utils/project.py +++ b/scrapy/utils/project.py @@ -1,9 +1,9 @@ +from __future__ import annotations + import os import warnings from importlib import import_module -from os import PathLike from pathlib import Path -from typing import Union from scrapy.exceptions import NotConfigured from scrapy.settings import Settings @@ -46,7 +46,7 @@ def project_data_dir(project: str = "default") -> str: return str(d) -def data_path(path: Union[str, PathLike], createdir: bool = False) -> str: +def data_path(path: str | os.PathLike[str], createdir: bool = False) -> str: """ Return the given path joined with the .scrapy data directory. If given an absolute path, return it unmodified. diff --git a/scrapy/utils/python.py b/scrapy/utils/python.py index 68ca96b69..6268af728 100644 --- a/scrapy/utils/python.py +++ b/scrapy/utils/python.py @@ -1,37 +1,36 @@ """ This module contains essential stuff that should've come with Python itself ;) """ -import collections.abc + +from __future__ import annotations + import gc import inspect import re import sys import weakref +from collections.abc import AsyncIterable, Iterable, Mapping from functools import partial, wraps from itertools import chain -from typing import ( - Any, - AsyncGenerator, - AsyncIterable, - AsyncIterator, - Callable, - Dict, - Generator, - Iterable, - Iterator, - List, - Mapping, - Optional, - Pattern, - Tuple, - Union, - overload, -) +from typing import TYPE_CHECKING, Any, TypeVar, overload from scrapy.utils.asyncgen import as_async_generator +if TYPE_CHECKING: + from collections.abc import AsyncIterator, Callable, Iterator + from re import Pattern -def flatten(x: Iterable) -> list: + # typing.Concatenate and typing.ParamSpec require Python 3.10 + from typing_extensions import Concatenate, ParamSpec + + _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 @@ -51,7 +50,7 @@ def flatten(x: Iterable) -> list: return list(iflatten(x)) -def iflatten(x: Iterable) -> Iterable: +def iflatten(x: Iterable[Any]) -> Iterable[Any]: """iflatten(sequence) -> iterator Similar to ``.flatten()``, but returns iterator instead""" @@ -86,10 +85,10 @@ def is_listlike(x: Any) -> bool: return hasattr(x, "__iter__") and not isinstance(x, (str, bytes)) -def unique(list_: Iterable, key: Callable[[Any], Any] = lambda x: x) -> list: +def unique(list_: Iterable[_T], key: Callable[[_T], Any] = lambda x: x) -> list[_T]: """efficient function to uniquify a list preserving item order""" seen = set() - result = [] + result: list[_T] = [] for item in list_: seenkey = key(item) if seenkey in seen: @@ -100,7 +99,7 @@ def unique(list_: Iterable, key: Callable[[Any], Any] = lambda x: x) -> list: def to_unicode( - text: Union[str, bytes], encoding: Optional[str] = None, errors: str = "strict" + text: str | bytes, encoding: str | None = None, errors: str = "strict" ) -> str: """Return the unicode representation of a bytes object ``text``. If ``text`` is already an unicode object, return it as-is.""" @@ -117,7 +116,7 @@ def to_unicode( def to_bytes( - text: Union[str, bytes], encoding: Optional[str] = None, errors: str = "strict" + text: str | bytes, encoding: str | None = None, errors: str = "strict" ) -> bytes: """Return the binary representation of ``text``. If ``text`` is already a bytes object, return it as-is.""" @@ -133,8 +132,8 @@ def to_bytes( def re_rsearch( - pattern: Union[str, Pattern], text: str, chunk_size: int = 1024 -) -> Optional[Tuple[int, int]]: + pattern: str | Pattern[str], text: str, chunk_size: int = 1024 +) -> tuple[int, int] | None: """ This function does a reverse search in a text using a regular expression given in the attribute 'pattern'. @@ -148,7 +147,7 @@ def re_rsearch( the start position of the match, and the ending (regarding the entire text). """ - def _chunk_iter() -> Generator[Tuple[str, int], Any, None]: + def _chunk_iter() -> Iterable[tuple[str, int]]: offset = len(text) while True: offset -= chunk_size * 1024 @@ -161,21 +160,26 @@ def re_rsearch( pattern = re.compile(pattern) for chunk, offset in _chunk_iter(): - matches = [match for match in pattern.finditer(chunk)] + matches = list(pattern.finditer(chunk)) if matches: start, end = matches[-1].span() return offset + start, offset + end return None -def memoizemethod_noargs(method: Callable) -> Callable: +_SelfT = TypeVar("_SelfT") + + +def memoizemethod_noargs( + method: Callable[Concatenate[_SelfT, _P], _T] +) -> Callable[Concatenate[_SelfT, _P], _T]: """Decorator to cache the result of a method (without arguments) using a weak reference to its object """ - cache: weakref.WeakKeyDictionary[Any, Any] = weakref.WeakKeyDictionary() + cache: weakref.WeakKeyDictionary[_SelfT, _T] = weakref.WeakKeyDictionary() @wraps(method) - def new_method(self: Any, *args: Any, **kwargs: Any) -> Any: + def new_method(self: _SelfT, *args: _P.args, **kwargs: _P.kwargs) -> _T: if self not in cache: cache[self] = method(self, *args, **kwargs) return cache[self] @@ -197,12 +201,12 @@ def binary_is_text(data: bytes) -> bool: return all(c not in _BINARYCHARS for c in data) -def get_func_args(func: Callable, stripself: bool = False) -> List[str]: +def get_func_args(func: Callable[..., Any], stripself: bool = False) -> list[str]: """Return the argument name list of a callable object""" if not callable(func): raise TypeError(f"func must be callable, got '{type(func).__name__}'") - args: List[str] = [] + args: list[str] = [] try: sig = inspect.signature(func) except ValueError: @@ -227,7 +231,7 @@ def get_func_args(func: Callable, stripself: bool = False) -> List[str]: return args -def get_spec(func: Callable) -> Tuple[List[str], Dict[str, Any]]: +def get_spec(func: Callable[..., Any]) -> tuple[list[str], dict[str, Any]]: """Returns (args, kwargs) tuple for a function >>> import re >>> get_spec(re.match) @@ -251,12 +255,12 @@ def get_spec(func: Callable) -> Tuple[List[str], Dict[str, Any]]: if inspect.isfunction(func) or inspect.ismethod(func): spec = inspect.getfullargspec(func) - elif hasattr(func, "__call__"): + elif hasattr(func, "__call__"): # noqa: B004 spec = inspect.getfullargspec(func.__call__) else: raise TypeError(f"{type(func)} is not callable") - defaults: Tuple[Any, ...] = spec.defaults or () + defaults: tuple[Any, ...] = spec.defaults or () firstdefault = len(spec.args) - len(defaults) args = spec.args[:firstdefault] @@ -265,7 +269,7 @@ def get_spec(func: Callable) -> Tuple[List[str], Dict[str, Any]]: def equal_attributes( - obj1: Any, obj2: Any, attributes: Optional[List[Union[str, Callable]]] + obj1: Any, obj2: Any, attributes: list[str | Callable[[Any], Any]] | None ) -> bool: """Compare two objects attributes""" # not attributes given return False by default @@ -285,22 +289,22 @@ def equal_attributes( @overload -def without_none_values(iterable: Mapping) -> dict: - ... +def without_none_values(iterable: Mapping[_KT, _VT]) -> dict[_KT, _VT]: ... @overload -def without_none_values(iterable: Iterable) -> Iterable: - ... +def without_none_values(iterable: Iterable[_KT]) -> Iterable[_KT]: ... -def without_none_values(iterable: Union[Mapping, Iterable]) -> Union[dict, Iterable]: +def without_none_values( + iterable: Mapping[_KT, _VT] | Iterable[_KT] +) -> dict[_KT, _VT] | Iterable[_KT]: """Return a copy of ``iterable`` with all ``None`` entries removed. If ``iterable`` is a mapping, return a dictionary where all pairs that have value ``None`` have been removed. """ - if isinstance(iterable, collections.abc.Mapping): + if isinstance(iterable, Mapping): return {k: v for k, v in iterable.items() if v is not None} else: # the iterable __init__ must take another iterable @@ -315,7 +319,7 @@ def global_object_name(obj: Any) -> str: >>> global_object_name(Request) 'scrapy.http.request.Request' """ - return f"{obj.__module__}.{obj.__name__}" + return f"{obj.__module__}.{obj.__qualname__}" if hasattr(sys, "pypy_version_info"): @@ -331,43 +335,45 @@ else: gc.collect() -class MutableChain(Iterable): +class MutableChain(Iterable[_T]): """ Thin wrapper around itertools.chain, allowing to add iterables "in-place" """ - def __init__(self, *args: Iterable): - self.data = chain.from_iterable(args) + def __init__(self, *args: Iterable[_T]): + self.data: Iterator[_T] = chain.from_iterable(args) - def extend(self, *iterables: Iterable) -> None: + def extend(self, *iterables: Iterable[_T]) -> None: self.data = chain(self.data, chain.from_iterable(iterables)) - def __iter__(self) -> Iterator: + def __iter__(self) -> Iterator[_T]: return self - def __next__(self) -> Any: + def __next__(self) -> _T: return next(self.data) -async def _async_chain(*iterables: Union[Iterable, AsyncIterable]) -> AsyncGenerator: +async def _async_chain( + *iterables: Iterable[_T] | AsyncIterable[_T], +) -> AsyncIterator[_T]: for it in iterables: async for o in as_async_generator(it): yield o -class MutableAsyncChain(AsyncIterable): +class MutableAsyncChain(AsyncIterable[_T]): """ Similar to MutableChain but for async iterables """ - def __init__(self, *args: Union[Iterable, AsyncIterable]): - self.data = _async_chain(*args) + def __init__(self, *args: Iterable[_T] | AsyncIterable[_T]): + self.data: AsyncIterator[_T] = _async_chain(*args) - def extend(self, *iterables: Union[Iterable, AsyncIterable]) -> None: + def extend(self, *iterables: Iterable[_T] | AsyncIterable[_T]) -> None: self.data = _async_chain(self.data, _async_chain(*iterables)) - def __aiter__(self) -> AsyncIterator: + def __aiter__(self) -> AsyncIterator[_T]: return self - async def __anext__(self) -> Any: + async def __anext__(self) -> _T: return await self.data.__anext__() diff --git a/scrapy/utils/reactor.py b/scrapy/utils/reactor.py index ad3d1d8bc..f8904a9aa 100644 --- a/scrapy/utils/reactor.py +++ b/scrapy/utils/reactor.py @@ -1,8 +1,9 @@ +from __future__ import annotations + import asyncio import sys -from asyncio import AbstractEventLoop, AbstractEventLoopPolicy from contextlib import suppress -from typing import Any, Callable, Dict, Optional, Sequence, Type +from typing import TYPE_CHECKING, Any, Generic, TypeVar from warnings import catch_warnings, filterwarnings, warn from twisted.internet import asyncioreactor, error @@ -11,8 +12,22 @@ from twisted.internet.base import DelayedCall from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.utils.misc import load_object +if TYPE_CHECKING: + from asyncio import AbstractEventLoop, AbstractEventLoopPolicy + from collections.abc import Callable -def listen_tcp(portrange, host, factory): + from twisted.internet.protocol import ServerFactory + from twisted.internet.tcp import Port + + # typing.ParamSpec requires Python 3.10 + from typing_extensions import ParamSpec + + _P = ParamSpec("_P") + +_T = TypeVar("_T") + + +def listen_tcp(portrange: list[int], host: str, factory: ServerFactory) -> Port: # type: ignore[return] """Like reactor.listenTCP but tries different ports in a range.""" from twisted.internet import reactor @@ -20,8 +35,6 @@ def listen_tcp(portrange, host, factory): raise ValueError(f"invalid portrange: {portrange}") if not portrange: return reactor.listenTCP(0, factory, interface=host) - if not hasattr(portrange, "__iter__"): - return reactor.listenTCP(portrange, factory, interface=host) if len(portrange) == 1: return reactor.listenTCP(portrange[0], factory, interface=host) for x in range(portrange[0], portrange[1] + 1): @@ -32,16 +45,16 @@ def listen_tcp(portrange, host, factory): raise -class CallLaterOnce: +class CallLaterOnce(Generic[_T]): """Schedule a function to be called in the next reactor loop, but only if it hasn't been already scheduled since the last time it ran. """ - def __init__(self, func: Callable, *a: Any, **kw: Any): - self._func: Callable = func - self._a: Sequence[Any] = a - self._kw: Dict[str, Any] = kw - self._call: Optional[DelayedCall] = None + def __init__(self, func: Callable[_P, _T], *a: _P.args, **kw: _P.kwargs): + self._func: Callable[_P, _T] = func + self._a: tuple[Any, ...] = a + self._kw: dict[str, Any] = kw + self._call: DelayedCall | None = None def schedule(self, delay: float = 0) -> None: from twisted.internet import reactor @@ -53,7 +66,7 @@ class CallLaterOnce: if self._call: self._call.cancel() - def __call__(self) -> Any: + def __call__(self) -> _T: self._call = None return self._func(*self._a, **self._kw) @@ -84,17 +97,15 @@ def get_asyncio_event_loop_policy() -> AbstractEventLoopPolicy: def _get_asyncio_event_loop_policy() -> AbstractEventLoopPolicy: policy = asyncio.get_event_loop_policy() - if ( - sys.version_info >= (3, 8) - and sys.platform == "win32" - and not isinstance(policy, asyncio.WindowsSelectorEventLoopPolicy) + 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: Optional[str] = None) -> None: +def install_reactor(reactor_path: str, event_loop_path: str | None = None) -> None: """Installs the :mod:`~twisted.internet.reactor` with the specified import path. Also installs the asyncio event loop with the specified import path if the asyncio reactor is enabled""" @@ -116,10 +127,10 @@ def _get_asyncio_event_loop() -> AbstractEventLoop: return set_asyncio_event_loop(None) -def set_asyncio_event_loop(event_loop_path: Optional[str]) -> AbstractEventLoop: +def set_asyncio_event_loop(event_loop_path: str | None) -> AbstractEventLoop: """Sets and returns the event loop with specified import path.""" if event_loop_path is not None: - event_loop_class: Type[AbstractEventLoop] = load_object(event_loop_path) + event_loop_class: type[AbstractEventLoop] = load_object(event_loop_path) event_loop = event_loop_class() asyncio.set_event_loop(event_loop) else: diff --git a/scrapy/utils/request.py b/scrapy/utils/request.py index db0b44cf4..82bdcb0f9 100644 --- a/scrapy/utils/request.py +++ b/scrapy/utils/request.py @@ -3,22 +3,12 @@ This module provides some useful functions for working with scrapy.http.Request objects """ +from __future__ import annotations + import hashlib import json import warnings -from typing import ( - TYPE_CHECKING, - Any, - Dict, - Generator, - Iterable, - List, - Optional, - Protocol, - Tuple, - Type, - Union, -) +from typing import TYPE_CHECKING, Any, Protocol from urllib.parse import urlunparse from weakref import WeakKeyDictionary @@ -32,26 +22,31 @@ from scrapy.utils.misc import load_object from scrapy.utils.python import to_bytes, to_unicode if TYPE_CHECKING: + from collections.abc import Iterable + + # typing.Self requires Python 3.11 + from typing_extensions import Self + from scrapy.crawler import Crawler -def _serialize_headers( - headers: Iterable[bytes], request: Request -) -> Generator[bytes, Any, None]: +def _serialize_headers(headers: Iterable[bytes], request: Request) -> Iterable[bytes]: for header in headers: if header in request.headers: yield header yield from request.headers.getlist(header) -_fingerprint_cache: "WeakKeyDictionary[Request, Dict[Tuple[Optional[Tuple[bytes, ...]], bool], bytes]]" +_fingerprint_cache: WeakKeyDictionary[ + Request, dict[tuple[tuple[bytes, ...] | None, bool], bytes] +] _fingerprint_cache = WeakKeyDictionary() def fingerprint( request: Request, *, - include_headers: Optional[Iterable[Union[bytes, str]]] = None, + include_headers: Iterable[bytes | str] | None = None, keep_fragments: bool = False, ) -> bytes: """ @@ -84,7 +79,7 @@ def fingerprint( If you want to include them, set the keep_fragments argument to True (for instance when handling requests with a headless browser). """ - processed_include_headers: Optional[Tuple[bytes, ...]] = None + processed_include_headers: tuple[bytes, ...] | None = None if include_headers: processed_include_headers = tuple( to_bytes(h.lower()) for h in sorted(include_headers) @@ -94,7 +89,7 @@ def fingerprint( if cache_key not in cache: # To decode bytes reliably (JSON does not support bytes), regardless of # character encoding, we use bytes.hex() - headers: Dict[str, List[str]] = {} + headers: dict[str, list[str]] = {} if processed_include_headers: for header in processed_include_headers: if header in request.headers: @@ -109,13 +104,12 @@ def fingerprint( "headers": headers, } fingerprint_json = json.dumps(fingerprint_data, sort_keys=True) - cache[cache_key] = hashlib.sha1(fingerprint_json.encode()).digest() + cache[cache_key] = hashlib.sha1(fingerprint_json.encode()).digest() # nosec return cache[cache_key] class RequestFingerprinterProtocol(Protocol): - def fingerprint(self, request: Request) -> bytes: - ... + def fingerprint(self, request: Request) -> bytes: ... class RequestFingerprinter: @@ -132,10 +126,10 @@ class RequestFingerprinter: """ @classmethod - def from_crawler(cls, crawler): + def from_crawler(cls, crawler: Crawler) -> Self: return cls(crawler) - def __init__(self, crawler: Optional["Crawler"] = None): + def __init__(self, crawler: Crawler | None = None): if crawler: implementation = crawler.settings.get( "REQUEST_FINGERPRINTER_IMPLEMENTATION" @@ -183,7 +177,7 @@ def request_httprepr(request: Request) -> bytes: return s -def referer_str(request: Request) -> Optional[str]: +def referer_str(request: Request) -> str | None: """Return Referer HTTP header suitable for logging.""" referrer = request.headers.get("Referer") if referrer is None: @@ -191,13 +185,13 @@ def referer_str(request: Request) -> Optional[str]: return to_unicode(referrer, errors="replace") -def request_from_dict(d: dict, *, spider: Optional[Spider] = None) -> Request: +def request_from_dict(d: dict[str, Any], *, spider: Spider | None = None) -> Request: """Create a :class:`~scrapy.Request` object from a dict. If a spider is given, it will try to resolve the callbacks looking at the spider for methods with the same name. """ - request_cls: Type[Request] = load_object(d["_class"]) if "_class" in d else Request + request_cls: type[Request] = load_object(d["_class"]) if "_class" in d else Request kwargs = {key: value for key, value in d.items() if key in request_cls.attributes} if d.get("callback") and spider: kwargs["callback"] = _get_method(spider, d["callback"]) diff --git a/scrapy/utils/response.py b/scrapy/utils/response.py index 77d54aff9..ecc83d1c8 100644 --- a/scrapy/utils/response.py +++ b/scrapy/utils/response.py @@ -2,24 +2,30 @@ This module provides some useful functions for working with scrapy.http.Response objects """ + +from __future__ import annotations + import os import re import tempfile import webbrowser -from typing import Any, Callable, Iterable, Tuple, Union +from typing import TYPE_CHECKING, Any from weakref import WeakKeyDictionary from twisted.web import http from w3lib import html -import scrapy -from scrapy.http.response import Response from scrapy.utils.python import to_bytes, to_unicode -_baseurl_cache: "WeakKeyDictionary[Response, str]" = WeakKeyDictionary() +if TYPE_CHECKING: + from collections.abc import Callable, Iterable + + from scrapy.http import Response, TextResponse + +_baseurl_cache: WeakKeyDictionary[Response, str] = WeakKeyDictionary() -def get_base_url(response: "scrapy.http.response.text.TextResponse") -> str: +def get_base_url(response: TextResponse) -> str: """Return the base url of the given response, joined with the response url""" if response not in _baseurl_cache: text = response.text[0:4096] @@ -29,15 +35,15 @@ def get_base_url(response: "scrapy.http.response.text.TextResponse") -> str: return _baseurl_cache[response] -_metaref_cache: "WeakKeyDictionary[Response, Union[Tuple[None, None], Tuple[float, str]]]" = ( +_metaref_cache: WeakKeyDictionary[Response, tuple[None, None] | tuple[float, str]] = ( WeakKeyDictionary() ) def get_meta_refresh( - response: "scrapy.http.response.text.TextResponse", + response: TextResponse, ignore_tags: Iterable[str] = ("script", "noscript"), -) -> Union[Tuple[None, None], Tuple[float, str]]: +) -> tuple[None, None] | tuple[float, str]: """Parse the http-equiv refresh parameter from the given response""" if response not in _metaref_cache: text = response.text[0:4096] @@ -47,22 +53,44 @@ def get_meta_refresh( return _metaref_cache[response] -def response_status_message(status: Union[bytes, float, int, str]) -> str: +def response_status_message(status: bytes | float | int | str) -> str: """Return status code plus status text descriptive message""" status_int = int(status) message = http.RESPONSES.get(status_int, "Unknown Status") return f"{status_int} {to_unicode(message)}" +def _remove_html_comments(body: bytes) -> bytes: + start = body.find(b"", start + 1) + if end == -1: + return body[:start] + else: + body = body[:start] + body[end + 3 :] + start = body.find(b"", b"", body, flags=re.DOTALL) - body = re.sub(rb"(|\s.*?>))", to_bytes(repl), body) + _remove_html_comments(body) + repl = rf'\0' + body = re.sub(rb"]*?>)", to_bytes(repl), body, count=1) ext = ".html" elif isinstance(response, TextResponse): ext = ".txt" diff --git a/scrapy/utils/signal.py b/scrapy/utils/signal.py index 21a12a19e..c1d3bfffb 100644 --- a/scrapy/utils/signal.py +++ b/scrapy/utils/signal.py @@ -1,8 +1,10 @@ """Helper functions for working with signals""" -import collections.abc + +from __future__ import annotations + import logging +from collections.abc import Sequence from typing import Any as TypingAny -from typing import List, Tuple from pydispatch.dispatcher import ( Anonymous, @@ -26,20 +28,16 @@ def send_catch_log( signal: TypingAny = Any, sender: TypingAny = Anonymous, *arguments: TypingAny, - **named: TypingAny -) -> List[Tuple[TypingAny, TypingAny]]: + **named: TypingAny, +) -> list[tuple[TypingAny, TypingAny]]: """Like pydispatcher.robust.sendRobust but it also logs errors and returns Failures instead of exceptions. """ dont_log = named.pop("dont_log", ()) - dont_log = ( - tuple(dont_log) - if isinstance(dont_log, collections.abc.Sequence) - else (dont_log,) - ) + dont_log = tuple(dont_log) if isinstance(dont_log, Sequence) else (dont_log,) dont_log += (StopDownload,) spider = named.get("spider", None) - responses: List[Tuple[TypingAny, TypingAny]] = [] + responses: list[tuple[TypingAny, TypingAny]] = [] for receiver in liveReceivers(getAllReceivers(sender, signal)): result: TypingAny try: @@ -72,8 +70,8 @@ def send_catch_log_deferred( signal: TypingAny = Any, sender: TypingAny = Anonymous, *arguments: TypingAny, - **named: TypingAny -) -> Deferred: + **named: TypingAny, +) -> Deferred[list[tuple[TypingAny, TypingAny]]]: """Like send_catch_log but supports returning deferreds on signal handlers. Returns a deferred that gets fired once all signal handlers deferreds were fired. @@ -91,17 +89,25 @@ def send_catch_log_deferred( dont_log = named.pop("dont_log", None) spider = named.get("spider", None) - dfds = [] + dfds: list[Deferred[tuple[TypingAny, TypingAny]]] = [] for receiver in liveReceivers(getAllReceivers(sender, signal)): - d = maybeDeferred_coro( + d: Deferred[TypingAny] = maybeDeferred_coro( robustApply, receiver, signal=signal, sender=sender, *arguments, **named ) d.addErrback(logerror, receiver) - d.addBoth(lambda result: (receiver, result)) - dfds.append(d) - d = DeferredList(dfds) - d.addCallback(lambda out: [x[1] for x in out]) - return d + # TODO https://pylint.readthedocs.io/en/latest/user_guide/messages/warning/cell-var-from-loop.html + d2: Deferred[tuple[TypingAny, TypingAny]] = d.addBoth( + lambda result: ( + receiver, # pylint: disable=cell-var-from-loop # noqa: B023 + result, + ) + ) + dfds.append(d2) + dl = DeferredList(dfds) + d3: Deferred[list[tuple[TypingAny, TypingAny]]] = dl.addCallback( + lambda out: [x[1] for x in out] + ) + return d3 def disconnect_all(signal: TypingAny = Any, sender: TypingAny = Any) -> None: diff --git a/scrapy/utils/sitemap.py b/scrapy/utils/sitemap.py index 3d2ecc9a7..c572580ae 100644 --- a/scrapy/utils/sitemap.py +++ b/scrapy/utils/sitemap.py @@ -4,27 +4,33 @@ Module for processing Sitemaps. Note: The main purpose of this module is to provide support for the SitemapSpider, its API is subject to change without notice. """ -from typing import Any, Dict, Generator, Iterator, Optional + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any from urllib.parse import urljoin -import lxml.etree +import lxml.etree # nosec + +if TYPE_CHECKING: + from collections.abc import Iterable, Iterator class Sitemap: """Class to parse Sitemap (type=urlset) and Sitemap Index (type=sitemapindex) files""" - def __init__(self, xmltext: str): + def __init__(self, xmltext: str | bytes): xmlp = lxml.etree.XMLParser( recover=True, remove_comments=True, resolve_entities=False ) - self._root = lxml.etree.fromstring(xmltext, parser=xmlp) + self._root = lxml.etree.fromstring(xmltext, parser=xmlp) # nosec rt = self._root.tag self.type = self._root.tag.split("}", 1)[1] if "}" in rt else rt - def __iter__(self) -> Iterator[Dict[str, Any]]: + def __iter__(self) -> Iterator[dict[str, Any]]: for elem in self._root.getchildren(): - d: Dict[str, Any] = {} + d: dict[str, Any] = {} for el in elem.getchildren(): tag = el.tag name = tag.split("}", 1)[1] if "}" in tag else tag @@ -40,8 +46,8 @@ class Sitemap: def sitemap_urls_from_robots( - robots_text: str, base_url: Optional[str] = None -) -> Generator[str, Any, None]: + robots_text: str, base_url: str | None = None +) -> Iterable[str]: """Return an iterator over all sitemap urls contained in the given robots.txt file """ diff --git a/scrapy/utils/spider.py b/scrapy/utils/spider.py index 704df8657..e58eb8134 100644 --- a/scrapy/utils/spider.py +++ b/scrapy/utils/spider.py @@ -2,31 +2,22 @@ from __future__ import annotations import inspect import logging -from types import CoroutineType, ModuleType -from typing import ( - TYPE_CHECKING, - Any, - AsyncGenerator, - Generator, - Iterable, - Literal, - Optional, - Type, - TypeVar, - Union, - overload, -) +from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload -from twisted.internet.defer import Deferred - -from scrapy import Request from scrapy.spiders import Spider from scrapy.utils.defer import deferred_from_coro from scrapy.utils.misc import arg_to_iter if TYPE_CHECKING: + from collections.abc import AsyncGenerator, Iterable + from types import CoroutineType, ModuleType + + from twisted.internet.defer import Deferred + + from scrapy import Request from scrapy.spiderloader import SpiderLoader + logger = logging.getLogger(__name__) _T = TypeVar("_T") @@ -34,21 +25,20 @@ _T = TypeVar("_T") # https://stackoverflow.com/questions/60222982 @overload -def iterate_spider_output(result: AsyncGenerator) -> AsyncGenerator: # type: ignore[misc] - ... +def iterate_spider_output(result: AsyncGenerator[_T]) -> AsyncGenerator[_T]: ... # type: ignore[overload-overlap] @overload -def iterate_spider_output(result: CoroutineType) -> Deferred: - ... +def iterate_spider_output(result: CoroutineType[Any, Any, _T]) -> Deferred[_T]: ... @overload -def iterate_spider_output(result: _T) -> Iterable: - ... +def iterate_spider_output(result: _T) -> Iterable[Any]: ... -def iterate_spider_output(result: Any) -> Union[Iterable, AsyncGenerator, Deferred]: +def iterate_spider_output( + result: Any, +) -> Iterable[Any] | AsyncGenerator[_T] | Deferred[_T]: if inspect.isasyncgen(result): return result if inspect.iscoroutine(result): @@ -58,7 +48,7 @@ def iterate_spider_output(result: Any) -> Union[Iterable, AsyncGenerator, Deferr return arg_to_iter(deferred_from_coro(result)) -def iter_spider_classes(module: ModuleType) -> Generator[Type[Spider], Any, None]: +def iter_spider_classes(module: ModuleType) -> Iterable[type[Spider]]: """Return an iterator over all spider classes defined in the given module that can be instantiated (i.e. which have name) """ @@ -80,11 +70,10 @@ def iter_spider_classes(module: ModuleType) -> Generator[Type[Spider], Any, None def spidercls_for_request( spider_loader: SpiderLoader, request: Request, - default_spidercls: Type[Spider], + default_spidercls: type[Spider], log_none: bool = ..., log_multiple: bool = ..., -) -> Type[Spider]: - ... +) -> type[Spider]: ... @overload @@ -94,8 +83,7 @@ def spidercls_for_request( default_spidercls: Literal[None], log_none: bool = ..., log_multiple: bool = ..., -) -> Optional[Type[Spider]]: - ... +) -> type[Spider] | None: ... @overload @@ -105,17 +93,16 @@ def spidercls_for_request( *, log_none: bool = ..., log_multiple: bool = ..., -) -> Optional[Type[Spider]]: - ... +) -> type[Spider] | None: ... def spidercls_for_request( spider_loader: SpiderLoader, request: Request, - default_spidercls: Optional[Type[Spider]] = None, + default_spidercls: type[Spider] | None = None, log_none: bool = False, log_multiple: bool = False, -) -> Optional[Type[Spider]]: +) -> type[Spider] | None: """Return a spider class that handles the given Request. This will look for the spiders that can handle the given request (using diff --git a/scrapy/utils/ssl.py b/scrapy/utils/ssl.py index e74769c65..7d46cbd4f 100644 --- a/scrapy/utils/ssl.py +++ b/scrapy/utils/ssl.py @@ -1,12 +1,16 @@ -from typing import Any, Optional +from __future__ import annotations -import OpenSSL._util as pyOpenSSLutil # type: ignore[import-untyped] +from typing import TYPE_CHECKING, Any + +import OpenSSL._util as pyOpenSSLutil import OpenSSL.SSL import OpenSSL.version -from OpenSSL.crypto import X509Name from scrapy.utils.python import to_unicode +if TYPE_CHECKING: + from OpenSSL.crypto import X509Name + def ffi_buf_to_string(buf: Any) -> str: return to_unicode(pyOpenSSLutil.ffi.string(buf)) @@ -16,13 +20,13 @@ def x509name_to_string(x509name: X509Name) -> str: # from OpenSSL.crypto.X509Name.__repr__ result_buffer: Any = pyOpenSSLutil.ffi.new("char[]", 512) pyOpenSSLutil.lib.X509_NAME_oneline( - x509name._name, result_buffer, len(result_buffer) # type: ignore[attr-defined] + x509name._name, result_buffer, len(result_buffer) ) return ffi_buf_to_string(result_buffer) -def get_temp_key_info(ssl_object: Any) -> Optional[str]: +def get_temp_key_info(ssl_object: Any) -> str | None: # adapted from OpenSSL apps/s_cb.c::ssl_print_tmp_key() if not hasattr(pyOpenSSLutil.lib, "SSL_get_server_tmp_key"): # removed in cryptography 40.0.0 diff --git a/scrapy/utils/template.py b/scrapy/utils/template.py index 6b22f3bfa..3e4dae5c8 100644 --- a/scrapy/utils/template.py +++ b/scrapy/utils/template.py @@ -1,13 +1,17 @@ """Helper functions for working with templates""" +from __future__ import annotations + import re import string -from os import PathLike from pathlib import Path -from typing import Any, Union +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from os import PathLike -def render_templatefile(path: Union[str, PathLike], **kwargs: Any) -> None: +def render_templatefile(path: str | PathLike, **kwargs: Any) -> None: path_obj = Path(path) raw = path_obj.read_text("utf8") diff --git a/scrapy/utils/test.py b/scrapy/utils/test.py index 7a8c5c859..d65f2a76d 100644 --- a/scrapy/utils/test.py +++ b/scrapy/utils/test.py @@ -2,21 +2,31 @@ This module contains some assorted functions used in tests """ +from __future__ import annotations + import asyncio import os from importlib import import_module from pathlib import Path from posixpath import split -from typing import Any, Coroutine, Dict, List, Optional, Tuple, Type +from typing import TYPE_CHECKING, Any, TypeVar from unittest import TestCase, mock -from twisted.internet.defer import Deferred from twisted.trial.unittest import SkipTest from scrapy import Spider from scrapy.crawler import Crawler from scrapy.utils.boto import is_botocore_available +if TYPE_CHECKING: + from collections.abc import Awaitable + + from twisted.internet.defer import Deferred + from twisted.web.client import Response as TxResponse + + +_T = TypeVar("_T") + def assert_gcs_environ() -> None: if "GCS_PROJECT_ID" not in os.environ: @@ -30,7 +40,7 @@ def skip_if_no_boto() -> None: def get_gcs_content_and_delete( bucket: Any, path: str -) -> Tuple[bytes, List[Dict[str, str]], Any]: +) -> tuple[bytes, list[dict[str, str]], Any]: from google.cloud import storage client = storage.Client(project=os.environ.get("GCS_PROJECT_ID")) @@ -57,7 +67,7 @@ def get_ftp_content_and_delete( ftp.login(username, password) if use_active_mode: ftp.set_pasv(False) - ftp_data: List[bytes] = [] + ftp_data: list[bytes] = [] def buffer_data(data: bytes) -> None: ftp_data.append(data) @@ -74,8 +84,8 @@ class TestSpider(Spider): def get_crawler( - spidercls: Optional[Type[Spider]] = None, - settings_dict: Optional[Dict[str, Any]] = None, + spidercls: type[Spider] | None = None, + settings_dict: dict[str, Any] | None = None, prevent_warnings: bool = True, ) -> Crawler: """Return an unconfigured Crawler object. If settings_dict is given, it @@ -85,7 +95,7 @@ def get_crawler( from scrapy.crawler import CrawlerRunner # Set by default settings that prevent deprecation warnings. - settings: Dict[str, Any] = {} + settings: dict[str, Any] = {} settings.update(settings_dict or {}) runner = CrawlerRunner(settings) crawler = runner.create_crawler(spidercls or TestSpider) @@ -100,7 +110,7 @@ def get_pythonpath() -> str: return str(Path(scrapy_path).parent) + os.pathsep + os.environ.get("PYTHONPATH", "") -def get_testenv() -> Dict[str, str]: +def get_testenv() -> dict[str, str]: """Return a OS environment dict suitable to fork processes that need to import this installation of Scrapy, instead of a system installed one. """ @@ -110,7 +120,7 @@ def get_testenv() -> Dict[str, str]: def assert_samelines( - testcase: TestCase, text1: str, text2: str, msg: Optional[str] = None + 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 @@ -118,14 +128,14 @@ def assert_samelines( testcase.assertEqual(text1.splitlines(), text2.splitlines(), msg) -def get_from_asyncio_queue(value: Any) -> Coroutine: - q: asyncio.Queue = asyncio.Queue() +def get_from_asyncio_queue(value: _T) -> Awaitable[_T]: + q: asyncio.Queue[_T] = asyncio.Queue() getter = q.get() q.put_nowait(value) return getter -def mock_google_cloud_storage() -> Tuple[Any, Any, Any]: +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. """ @@ -142,7 +152,7 @@ def mock_google_cloud_storage() -> Tuple[Any, Any, Any]: return (client_mock, bucket_mock, blob_mock) -def get_web_client_agent_req(url: str) -> Deferred: +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 diff --git a/scrapy/utils/testproc.py b/scrapy/utils/testproc.py index 3bdffcaa7..05e04e2d1 100644 --- a/scrapy/utils/testproc.py +++ b/scrapy/utils/testproc.py @@ -2,16 +2,20 @@ from __future__ import annotations import os import sys -from typing import Iterable, List, Optional, Tuple, cast +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 twisted.python.failure import Failure + +if TYPE_CHECKING: + from collections.abc import Iterable + + from twisted.python.failure import Failure class ProcessTest: - command: Optional[str] = None + command: str | None = None prefix = [sys.executable, "-m", "scrapy.cmdline"] cwd = os.getcwd() # trial chdirs to temp dir @@ -19,8 +23,8 @@ class ProcessTest: self, args: Iterable[str], check_code: bool = True, - settings: Optional[str] = None, - ) -> Deferred: + settings: str | None = None, + ) -> Deferred[TestProcessProtocol]: from twisted.internet import reactor env = os.environ.copy() @@ -34,8 +38,8 @@ class ProcessTest: return pp.deferred def _process_finished( - self, pp: TestProcessProtocol, cmd: List[str], check_code: bool - ) -> Tuple[int, bytes, bytes]: + self, pp: TestProcessProtocol, cmd: list[str], check_code: bool + ) -> tuple[int, bytes, bytes]: if pp.exitcode and check_code: msg = f"process {cmd} exit with code {pp.exitcode}" msg += f"\n>>> stdout <<<\n{pp.out.decode()}" @@ -47,10 +51,10 @@ class ProcessTest: class TestProcessProtocol(ProcessProtocol): def __init__(self) -> None: - self.deferred: Deferred = Deferred() + self.deferred: Deferred[TestProcessProtocol] = Deferred() self.out: bytes = b"" self.err: bytes = b"" - self.exitcode: Optional[int] = None + self.exitcode: int | None = None def outReceived(self, data: bytes) -> None: self.out += data diff --git a/scrapy/utils/testsite.py b/scrapy/utils/testsite.py index de9ce992a..ca1f68116 100644 --- a/scrapy/utils/testsite.py +++ b/scrapy/utils/testsite.py @@ -15,12 +15,12 @@ class SiteTest: super().tearDown() self.site.stopListening() - def url(self, path): + def url(self, path: str) -> str: return urljoin(self.baseurl, path) class NoMetaRefreshRedirect(util.Redirect): - def render(self, request): + def render(self, request: server.Request) -> bytes: content = util.Redirect.render(self, request) return content.replace( b'http-equiv="refresh"', b'http-no-equiv="do-not-refresh-me"' diff --git a/scrapy/utils/trackref.py b/scrapy/utils/trackref.py index 9ff9a273f..5eec1c10f 100644 --- a/scrapy/utils/trackref.py +++ b/scrapy/utils/trackref.py @@ -9,19 +9,23 @@ and no performance penalty at all when disabled (as object_ref becomes just an alias to object in that case). """ +from __future__ import annotations + from collections import defaultdict from operator import itemgetter from time import time -from typing import TYPE_CHECKING, Any, DefaultDict, Iterable +from typing import TYPE_CHECKING, Any from weakref import WeakKeyDictionary if TYPE_CHECKING: + from collections.abc import Iterable + # typing.Self requires Python 3.11 from typing_extensions import Self NoneType = type(None) -live_refs: DefaultDict[type, WeakKeyDictionary] = defaultdict(WeakKeyDictionary) +live_refs: defaultdict[type, WeakKeyDictionary] = defaultdict(WeakKeyDictionary) class object_ref: @@ -29,7 +33,7 @@ class object_ref: __slots__ = () - def __new__(cls, *args: Any, **kwargs: Any) -> "Self": + def __new__(cls, *args: Any, **kwargs: Any) -> Self: obj = object.__new__(cls) live_refs[cls][obj] = time() return obj diff --git a/scrapy/utils/url.py b/scrapy/utils/url.py index 22b4197f9..e0a2973f7 100644 --- a/scrapy/utils/url.py +++ b/scrapy/utils/url.py @@ -5,8 +5,11 @@ library. Some of the functions that used to be imported from this module have been moved to the w3lib.url module. Always import those from there instead. """ + +from __future__ import annotations + import re -from typing import TYPE_CHECKING, Iterable, Optional, Type, Union, cast +from typing import TYPE_CHECKING, Union, cast from urllib.parse import ParseResult, urldefrag, urlparse, urlunparse # scrapy.utils.url was moved to w3lib.url and import * ensures this @@ -17,6 +20,8 @@ from w3lib.url import _safe_chars, _unquotepath # noqa: F401 from scrapy.utils.python import to_unicode if TYPE_CHECKING: + from collections.abc import Iterable + from scrapy import Spider @@ -32,7 +37,7 @@ def url_is_from_any_domain(url: UrlT, domains: Iterable[str]) -> bool: return any((host == d) or (host.endswith(f".{d}")) for d in domains) -def url_is_from_spider(url: UrlT, spider: Type["Spider"]) -> bool: +def url_is_from_spider(url: UrlT, spider: type[Spider]) -> bool: """Return True if the url belongs to the given spider""" return url_is_from_any_domain( url, [spider.name] + list(getattr(spider, "allowed_domains", [])) @@ -45,7 +50,7 @@ def url_has_any_extension(url: UrlT, extensions: Iterable[str]) -> bool: return any(lowercase_path.endswith(ext) for ext in extensions) -def parse_url(url: UrlT, encoding: Optional[str] = None) -> ParseResult: +def parse_url(url: UrlT, encoding: str | None = None) -> ParseResult: """Return urlparsed url from the given argument (which could be an already parsed url) """ diff --git a/scrapy/utils/versions.py b/scrapy/utils/versions.py index 9b637bdb0..4e9e29286 100644 --- a/scrapy/utils/versions.py +++ b/scrapy/utils/versions.py @@ -1,10 +1,9 @@ import platform import sys -from typing import List, Tuple import cryptography import cssselect -import lxml.etree +import lxml.etree # nosec import parsel import twisted import w3lib @@ -13,7 +12,7 @@ import scrapy from scrapy.utils.ssl import get_openssl_version -def scrapy_components_versions() -> List[Tuple[str, str]]: +def scrapy_components_versions() -> list[tuple[str, str]]: lxml_version = ".".join(map(str, lxml.etree.LXML_VERSION)) libxml2_version = ".".join(map(str, lxml.etree.LIBXML_VERSION)) diff --git a/sep/sep-018.rst b/sep/sep-018.rst index 9ac62c090..13ab501ed 100644 --- a/sep/sep-018.rst +++ b/sep/sep-018.rst @@ -140,7 +140,7 @@ Example: The data flow with Spider Middleware v2 is as follows: -1. When a response arrives from the engine, it it passed through all the spider +1. When a response arrives from the engine, it is passed through all the spider middlewares (in descending order). The result of each middleware ``process_response`` is kept and then returned along with the spider callback result diff --git a/setup.py b/setup.py index 405633f55..ec9ac6597 100644 --- a/setup.py +++ b/setup.py @@ -6,22 +6,22 @@ version = (Path(__file__).parent / "scrapy/VERSION").read_text("ascii").strip() install_requires = [ - "Twisted>=18.9.0", - "cryptography>=36.0.0", + "Twisted>=21.7.0", + "cryptography>=37.0.0", "cssselect>=0.9.1", "itemloaders>=1.0.1", "parsel>=1.5.0", - "pyOpenSSL>=21.0.0", + "pyOpenSSL>=22.0.0", "queuelib>=1.4.2", "service_identity>=18.1.0", "w3lib>=1.17.0", "zope.interface>=5.1.0", "protego>=0.1.15", "itemadapter>=0.1.0", - "setuptools", "packaging", "tldextract", - "lxml>=4.4.1", + "lxml>=4.6.0", + "defusedxml>=0.7.1", ] extras_require = { ':platform_python_implementation == "CPython"': ["PyDispatcher>=2.0.5"], @@ -58,7 +58,6 @@ setup( "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -69,7 +68,7 @@ setup( "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", ], - python_requires=">=3.8", + python_requires=">=3.9", install_requires=install_requires, extras_require=extras_require, ) diff --git a/tests/CrawlerProcess/asyncio_deferred_signal.py b/tests/CrawlerProcess/asyncio_deferred_signal.py index 1afef4d24..028e3a08a 100644 --- a/tests/CrawlerProcess/asyncio_deferred_signal.py +++ b/tests/CrawlerProcess/asyncio_deferred_signal.py @@ -1,6 +1,7 @@ +from __future__ import annotations + import asyncio import sys -from typing import Optional from scrapy import Spider from scrapy.crawler import CrawlerProcess @@ -31,7 +32,7 @@ class UrlSpider(Spider): if __name__ == "__main__": - ASYNCIO_EVENT_LOOP: Optional[str] + ASYNCIO_EVENT_LOOP: str | None try: ASYNCIO_EVENT_LOOP = sys.argv[1] except IndexError: diff --git a/tests/CrawlerProcess/asyncio_enabled_reactor.py b/tests/CrawlerProcess/asyncio_enabled_reactor.py index 01d23c963..f013eed27 100644 --- a/tests/CrawlerProcess/asyncio_enabled_reactor.py +++ b/tests/CrawlerProcess/asyncio_enabled_reactor.py @@ -3,7 +3,7 @@ import sys from twisted.internet import asyncioreactor -if sys.version_info >= (3, 8) and sys.platform == "win32": +if sys.platform == "win32": asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) asyncioreactor.install(asyncio.get_event_loop()) diff --git a/tests/CrawlerProcess/asyncio_enabled_reactor_different_loop.py b/tests/CrawlerProcess/asyncio_enabled_reactor_different_loop.py index 9dc8ce46b..e9d6d8875 100644 --- a/tests/CrawlerProcess/asyncio_enabled_reactor_different_loop.py +++ b/tests/CrawlerProcess/asyncio_enabled_reactor_different_loop.py @@ -4,7 +4,7 @@ import sys from twisted.internet import asyncioreactor from twisted.python import log -if sys.version_info >= (3, 8) and sys.platform == "win32": +if sys.platform == "win32": asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) asyncioreactor.install(asyncio.get_event_loop()) diff --git a/tests/CrawlerProcess/asyncio_enabled_reactor_same_loop.py b/tests/CrawlerProcess/asyncio_enabled_reactor_same_loop.py index be9c83b95..c72a0a17c 100644 --- a/tests/CrawlerProcess/asyncio_enabled_reactor_same_loop.py +++ b/tests/CrawlerProcess/asyncio_enabled_reactor_same_loop.py @@ -4,7 +4,7 @@ import sys from twisted.internet import asyncioreactor from uvloop import Loop -if sys.version_info >= (3, 8) and sys.platform == "win32": +if sys.platform == "win32": asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) asyncio.set_event_loop(Loop()) asyncioreactor.install(asyncio.get_event_loop()) diff --git a/tests/CrawlerRunner/change_reactor.py b/tests/CrawlerRunner/change_reactor.py new file mode 100644 index 000000000..b20aa0c7c --- /dev/null +++ b/tests/CrawlerRunner/change_reactor.py @@ -0,0 +1,31 @@ +from scrapy import Spider +from scrapy.crawler import CrawlerRunner +from scrapy.utils.log import configure_logging + + +class NoRequestsSpider(Spider): + name = "no_request" + + custom_settings = { + "TWISTED_REACTOR": "twisted.internet.asyncioreactor.AsyncioSelectorReactor", + } + + def start_requests(self): + return [] + + +configure_logging({"LOG_FORMAT": "%(levelname)s: %(message)s", "LOG_LEVEL": "DEBUG"}) + + +from scrapy.utils.reactor import install_reactor + +install_reactor("twisted.internet.asyncioreactor.AsyncioSelectorReactor") + +runner = CrawlerRunner() + +d = runner.crawl(NoRequestsSpider) + +from twisted.internet import reactor + +d.addBoth(callback=lambda _: reactor.stop()) +reactor.run() diff --git a/tests/CrawlerRunner/ip_address.py b/tests/CrawlerRunner/ip_address.py index 23260ab0d..5bf7512bc 100644 --- a/tests/CrawlerRunner/ip_address.py +++ b/tests/CrawlerRunner/ip_address.py @@ -9,6 +9,7 @@ from twisted.python.runtime import platform 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 @@ -30,7 +31,7 @@ class LocalhostSpider(Spider): yield Request(self.url) def parse(self, response): - netloc = urlparse(response.url).netloc + netloc = urlparse_cached(response).netloc host = netloc.split(":")[0] self.logger.info(f"Host: {host}") self.logger.info(f"Type: {type(response.ip_address)}") diff --git a/tests/keys/mitmproxy-ca.pem b/tests/keys/mitmproxy-ca.pem index cdef75f99..61a690cc8 100644 --- a/tests/keys/mitmproxy-ca.pem +++ b/tests/keys/mitmproxy-ca.pem @@ -1,50 +1,47 @@ ------BEGIN PRIVATE KEY----- -MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCYp6U4G9YWITYB -/JlZ+Hd08c/9a157WVl03hbR2DSK8FnK+D8cp2dGzuTfC08w8M/yvVYPcbb7ZDiT -NUsVwboFvmr/6mN6M9uQioCRStrP6Rkm2Wuagyj+GjqLwogTJlPiPwEPhlMgz1BJ -u6jQQSgiMsxKWMkVz3pCYERUMRX0DEgYST9rjYUAwD4rPv8XXtLLSPs0VniIggUH -JrngDUrtoK5Wuf098NJPIwW8uE2ev+DXH2Iuwn2fNKt5lSYypJdUZjyamwuE6HFB -eIBAIIKijMz/8UV1+H8Q0OcU2Sva2FglHREQtA/S5FlpcuTZt/77Vnxv75y/0zls -90iyQ3E/AgMBAAECggEBAJA1dyAdM85uC04vKVNUJM1GDp0xS+0syBReJaKRI3nJ -epoCj+RqxGag1pdaYLI0G84NTPqECz9LOyLdqpPgEfKRIxWlf9oWmSnfnXskArd8 -VfVcWYl6tEPv1TToTZIBmCbYLBFVbLxG/GrbK6uokdhUsqbdXwEKok2IEaSTRlDn -v8BVXte00d9VEKKpmI6EY3f45uPQPHuJNcitP2HGW1mT/C6XoZR6wj+VvoRgUGQT -I7PuktbYpQlLV+oX0uZz9frPGhjydUq0Jti5v3QAJEb+7D0cKrkZW+7fYDx4YkRU -oDiuWEyO2kfpff52Qxs+xUXMiAyw6/8+TamKoAi1TIECgYEAyAzoztW6W4CjL2au -/hN5VmbAvuBxq1m1G5KgXM1myX9V2CgH6OKwzJQNSCEfKMNOjqxB99T7C3tMCjgG -gmbUzylTeciQFF+crrl2Rn/6qZS9dCo1hagb3K5eXMhLXoP425Y4sypNPPqULhPn -YrUDFNAf89rRLqP1KMPLZ+uO7EECgYEAw1lWPxGV+X85iQxYN9xoX85htfJSBXTf -dLirQ4bkykOxSA6ZzFuhDO/G373Q1rze4tmEO790uOCeaiXGgeWC1A+2PMO957i5 -9FqhDIkmerfdIttdEUMM9rQwuTcLnixGZkT5GHDzjtNinaIVB+pv7twRAESqN9dC -QXh7IF7g/X8CgYBMhQOX+hCqZ24D95cAAJrs/ajEWj2geVPZFCDa3oZulJJVeBpu -bieKWScra9/rS6mE0Ub6cTEFl0fisMNspcDI7NnNP3Y9FMVt3+rp1JIgw5AkGvEW -CtN9egUGIGcT5A8Qj0lo3slkhcSgS2S6UNq431MZh51z5askyJ/JREULAQKBgFrR -OatwfYzUfOcd+hVePpfr1rlDwqYOw6P8BoMKP2tZNR4Oy6maH7Fn98kk8eYjQGuu -PC+avqUEqCEpFrRlAwGbnFl7ltoXozvatmyhhmYe/Iur+ASCa5B2DQDOenQ6mTAK -eNPIDzMjSwGFzMk1UHx3it/ZDFmRlZfibzuJYIf5AoGBAIaPHk4qadK/XpcD4Wwx -BOsDEIz27DGWdwWfd5r3EcV4zX/wNzH0G1Z8eydNjUqKzufMZgFwpcTu0Evesl1/ -B8kC8sLHxQoG5SvBu4dBxMwKIU9O9uFnX5SUYZUDpCtUYyZ+GtGom41Jwg5ENrwy -HzPh2taMnCA0h1fNLFFBkw88 ------END PRIVATE KEY----- +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAuq7ujTpHoSwQn9/hFJT837jU/T7xLuyXjkAEfL7uVDuPWSdF +AJy+QJsuL6INMKMlxLUb1RRzxQgAmtYN1dIEbTPplffCNbfYm3FXg0mZlxg1UBg9 +rE1bPwuz/B+M76S35EIiKQlpaCFErLQi5oyhw9FIBvYLZxxgfeDfDPiXQlBvtHix +n9TFqNoLNZkAX+auh2Wj2SSjM74pBQWsuVZLkF5CAwYuMQkpEplCV/QHNX3ZeNdQ +YNFvpA7CxENa3sTZyHpeTmWoOdJXgJFveAWL4ZhSvkSd0HDuPPJp4JckETNESa9B +qOwFfj36SM+5dRiCwiuzwAQ+oaFjisMXuRbVFQIDAQABAoIBABqFaJmCupNgnboA +xcq1QdmMuiGCNCRs8zj/ykNoopYv4fUR+aEVI4gtI5obxRDwVJjF+/7BCZNnyCI3 +H78NN5jGA7zM9nfINwsaRor9xUasZ0KKNxTH5pslz/uVBeIzvfY9GPpIfoOPGmEI +tF6Zgw+9JyTqBoOvCdxIOpfupxqB8TQ0z4UbFUuBiEkGuJ+o8C1rX8Wze0JUl0qG +BOwhQtaCn/yrm+dTXZ5XaelJY5mcwgFy22Jiynmm6TbLhyZlACd2Q/MGak7o1TJL +QgvvGMlcVrK7MZ3TJN+wzwWfwAAjXnT3Xvd5pD5yunZJoNe8YyFOCMlh0swNG5Zt +0tGeX78CgYEA+m6gYGKTNWFnqlqMZRfGTqiqVZeVQKftcLdA1dkscnffRP9bvKOW +9TbgzoGHiyZnjZBDFTUuy67El8RXIMsxYy3GYuGRdUSLS63Fr2af4pBQIYvW6OmG +UZlcAP6ZAhUzn409XGlXaac3F30hFeKdC62+V5ZMnfPlVhHRCoKaaqsCgYEAvtV4 +FuU5sFKyhKPPV3rzaNZtL0swvtBIuODH1oAWhPNySQvCu+45W0EOOAPPpsYP4wGX +G+otOSp4RLdlVXNhkh1rpJzeK77KZ5ZY+ShkuHD+uL/iRARwl/gh2Ve1aqUrm1LE +9ldchmQGvLalN9HalzeW//xHA3X9SF4Vo16Dvz8CgYABeZlUOABp9hLoO/RLvCIc +4H1wV543bUXGvi2RlN/gJLiZ7W8a41PGSfZ1AOpNdYJyoQDkJRYLeRILWsqwlMHL +tb9PYci7ihXP8kwRxmb2rKbsK6iuYoG6BU83akh4bKuLKwfLfYtYQfXfG4uQV29Z +XEKcvXPiEkethBlZGH/UVQKBgCa9Pvum3OcmYob6mgSwOOl3XgLTyLlzns+pEehB +aFDk+rZJZOaxnYMg2boVS/oXCvKSSBKqnzOTo4aPlEqceZonzspD7fYDbSNKKhWq +VYf7qDno+g3EuPagsH5mh7V2gjutub4oTegaNiPpD/Ec8Lrx1f1xQRk6wogGUW4w +qZ4RAoGBAPg1LezV8mlesF5mhj+KubYP4l1Zf9geAeQprjDbFsA0BEAS2KsWgmwR +Ye1fmek7jDjCPLQ4Amq030mLJuQGEM3cZPqjKX2sBZ8fQcgw7pWJWMvKMTBA4Aah +zQx1KXwHJANMWq/0QSFDq/LGJ2OYMlV2F0tH3P5Kp7ZASTyc78ux +-----END RSA PRIVATE KEY----- -----BEGIN CERTIFICATE----- -MIIDoTCCAomgAwIBAgIGDodLQx9+MA0GCSqGSIb3DQEBCwUAMCgxEjAQBgNVBAMM -CW1pdG1wcm94eTESMBAGA1UECgwJbWl0bXByb3h5MB4XDTIwMDgxMjE3MDMyNloX -DTIzMDgxNDE3MDMyNlowKDESMBAGA1UEAwwJbWl0bXByb3h5MRIwEAYDVQQKDAlt -aXRtcHJveHkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCYp6U4G9YW -ITYB/JlZ+Hd08c/9a157WVl03hbR2DSK8FnK+D8cp2dGzuTfC08w8M/yvVYPcbb7 -ZDiTNUsVwboFvmr/6mN6M9uQioCRStrP6Rkm2Wuagyj+GjqLwogTJlPiPwEPhlMg -z1BJu6jQQSgiMsxKWMkVz3pCYERUMRX0DEgYST9rjYUAwD4rPv8XXtLLSPs0VniI -ggUHJrngDUrtoK5Wuf098NJPIwW8uE2ev+DXH2Iuwn2fNKt5lSYypJdUZjyamwuE -6HFBeIBAIIKijMz/8UV1+H8Q0OcU2Sva2FglHREQtA/S5FlpcuTZt/77Vnxv75y/ -0zls90iyQ3E/AgMBAAGjgdAwgc0wDwYDVR0TAQH/BAUwAwEB/zARBglghkgBhvhC -AQEEBAMCAgQweAYDVR0lBHEwbwYIKwYBBQUHAwEGCCsGAQUFBwMCBggrBgEFBQcD -BAYIKwYBBQUHAwgGCisGAQQBgjcCARUGCisGAQQBgjcCARYGCisGAQQBgjcKAwEG -CisGAQQBgjcKAwMGCisGAQQBgjcKAwQGCWCGSAGG+EIEATAOBgNVHQ8BAf8EBAMC -AQYwHQYDVR0OBBYEFBCsLPpFz3l9rOOfGmfs+VRc3jhJMA0GCSqGSIb3DQEBCwUA -A4IBAQADTpA15na6U5qqDCe0rr39fkS1/dY804Xnz7g/L3AsxPE1KOMijuJa8sKd -kKwba1173FwMupfK39zY8jUxL8Qprdi92RO6CpoFUsL/icpA///lYhzUSqt32qwe -gRNW3mtYBimOk6KH1NOfQnJolWpJh+g1OEsitQKEeKwIn5Hz+8/yS5tbwLgdnMlY -1/it1H70JSdE7nfJueqN4cFfBsm6XaHZzacJJmN7WP88fd+zztnSQsBFbLlnjnqj -envCDIwCrMywKNMqEBMwmBEGSAF47fVNYj6KzDAtMvBdDkYaHWpBf4tnFfk6v0wj -wiKjdLjCmJgjGAQjRw5VYJ8JI0XO +MIIDNTCCAh2gAwIBAgIUcGDiCmOuhfxMGFS/otcGGFkOSAEwDQYJKoZIhvcNAQEL +BQAwKDESMBAGA1UEAwwJbWl0bXByb3h5MRIwEAYDVQQKDAltaXRtcHJveHkwHhcN +MjQwMjI3MTMwNTQ4WhcNMzQwMjI2MTMwNTQ4WjAoMRIwEAYDVQQDDAltaXRtcHJv +eHkxEjAQBgNVBAoMCW1pdG1wcm94eTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC +AQoCggEBALqu7o06R6EsEJ/f4RSU/N+41P0+8S7sl45ABHy+7lQ7j1knRQCcvkCb +Li+iDTCjJcS1G9UUc8UIAJrWDdXSBG0z6ZX3wjW32JtxV4NJmZcYNVAYPaxNWz8L +s/wfjO+kt+RCIikJaWghRKy0IuaMocPRSAb2C2ccYH3g3wz4l0JQb7R4sZ/Uxaja +CzWZAF/mrodlo9kkozO+KQUFrLlWS5BeQgMGLjEJKRKZQlf0BzV92XjXUGDRb6QO +wsRDWt7E2ch6Xk5lqDnSV4CRb3gFi+GYUr5EndBw7jzyaeCXJBEzREmvQajsBX49 ++kjPuXUYgsIrs8AEPqGhY4rDF7kW1RUCAwEAAaNXMFUwDwYDVR0TAQH/BAUwAwEB +/zATBgNVHSUEDDAKBggrBgEFBQcDATAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYE +FOjFT0G7itqsrCij2InhRSfB0sEkMA0GCSqGSIb3DQEBCwUAA4IBAQCVMa5/xlH4 +GUbrWNMdxr9LL7Dh+vK0wYCfAsc/kO2zCq8iVt/MaqVLel/bKcQhvE5RZHvyep13 +x7378OfCqqHkDDDNroWIvij84ZtMUaM53tF13G/ZGOlNsoLNynWs9IVVvqGKsH7o +/buJ1RNArI/0irF0UD7qrMmo1p6SYanZhqdh2PphNy9NS3FsfrfnuWvf+/TRp9Ts +L8058B0p/LIL0OB5trYFircC3iKSOuRl0ERD2ufgSqsSVEYm1mc6UIxv+d1iFD+Q +8CRUF88icQXrec1TCbhh0CfdDxz+FYSTnW0DR0L75coa/CBmRxAjnrkLoXRr3Y1d +sTjU4zDdBcBw -----END CERTIFICATE----- diff --git a/tests/mocks/dummydbm.py b/tests/mocks/dummydbm.py index 2869ff8f7..a7f7f1356 100644 --- a/tests/mocks/dummydbm.py +++ b/tests/mocks/dummydbm.py @@ -1,6 +1,7 @@ """DBM-like dummy module""" -import collections -from typing import Any, DefaultDict + +from collections import defaultdict +from typing import Any class DummyDB(dict): @@ -13,7 +14,7 @@ class DummyDB(dict): error = KeyError -_DATABASES: DefaultDict[Any, DummyDB] = collections.defaultdict(DummyDB) +_DATABASES: defaultdict[Any, DummyDB] = defaultdict(DummyDB) def open(file, flag="r", mode=0o666): diff --git a/tests/mockserver.py b/tests/mockserver.py index 647b0682e..f5c12787a 100644 --- a/tests/mockserver.py +++ b/tests/mockserver.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import argparse import json import os @@ -7,12 +9,11 @@ from pathlib import Path from shutil import rmtree from subprocess import PIPE, Popen from tempfile import mkdtemp -from typing import Dict +from typing import TYPE_CHECKING from urllib.parse import urlencode from OpenSSL import SSL from twisted.internet import defer, reactor, ssl -from twisted.internet.protocol import ServerFactory from twisted.internet.task import deferLater from twisted.names import dns, error from twisted.names.server import DNSServerFactory @@ -23,6 +24,9 @@ from twisted.web.util import redirectTo from scrapy.utils.python import to_bytes, to_unicode +if TYPE_CHECKING: + from twisted.internet.protocol import ServerFactory + def getarg(request, name, default=None, type=None): if name in request.args: @@ -33,7 +37,7 @@ def getarg(request, name, default=None, type=None): return default -def get_mockserver_env() -> Dict[str, str]: +def get_mockserver_env() -> dict[str, str]: """Return a OS environment dict suitable to run mockserver processes.""" tests_path = Path(__file__).parent.parent @@ -189,10 +193,10 @@ class Raw(LeafResource): class Echo(LeafResource): def render_GET(self, request): output = { - "headers": dict( - (to_unicode(k), [to_unicode(v) for v in vs]) + "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)) diff --git a/tests/requirements.txt b/tests/requirements.txt deleted file mode 100644 index 5b75674f5..000000000 --- a/tests/requirements.txt +++ /dev/null @@ -1,18 +0,0 @@ -# Tests requirements -attrs -pexpect >= 4.8.0 -pyftpdlib >= 1.5.8 -pytest -pytest-cov==4.0.0 -pytest-xdist -sybil >= 1.3.0 # https://github.com/cjw296/sybil/issues/20#issuecomment-605433422 -testfixtures -uvloop; platform_system != "Windows" - -bpython # optional for shell wrapper tests -brotli; implementation_name != 'pypy' # optional for HTTP compress downloader middleware tests -# 1.1.0 is broken on PyPy: https://github.com/google/brotli/issues/1072 -brotli==1.0.9; implementation_name == 'pypy' # optional for HTTP compress downloader middleware tests -zstandard; implementation_name != 'pypy' # optional for HTTP compress downloader middleware tests -ipython -pywin32; sys_platform == "win32" diff --git a/tests/sample_data/compressed/bomb-br.bin b/tests/sample_data/compressed/bomb-br.bin new file mode 100644 index 000000000..50059866f --- /dev/null +++ b/tests/sample_data/compressed/bomb-br.bin @@ -0,0 +1,2 @@ +;nުVp SmoY2 +()-д=_o \ No newline at end of file diff --git a/tests/sample_data/compressed/bomb-deflate.bin b/tests/sample_data/compressed/bomb-deflate.bin new file mode 100644 index 000000000..3598aca07 Binary files /dev/null and b/tests/sample_data/compressed/bomb-deflate.bin differ diff --git a/tests/sample_data/compressed/bomb-gzip.bin b/tests/sample_data/compressed/bomb-gzip.bin new file mode 100644 index 000000000..64aa0c369 Binary files /dev/null and b/tests/sample_data/compressed/bomb-gzip.bin differ diff --git a/tests/sample_data/compressed/bomb-zstd.bin b/tests/sample_data/compressed/bomb-zstd.bin new file mode 100644 index 000000000..4b0efa8a4 Binary files /dev/null and b/tests/sample_data/compressed/bomb-zstd.bin differ diff --git a/tests/sample_data/compressed/html-gzip-deflate-gzip.bin b/tests/sample_data/compressed/html-gzip-deflate-gzip.bin new file mode 100644 index 000000000..d66f4c5a0 Binary files /dev/null and b/tests/sample_data/compressed/html-gzip-deflate-gzip.bin differ diff --git a/tests/sample_data/compressed/html-gzip-deflate.bin b/tests/sample_data/compressed/html-gzip-deflate.bin new file mode 100644 index 000000000..5066842ef Binary files /dev/null and b/tests/sample_data/compressed/html-gzip-deflate.bin differ diff --git a/tests/spiders.py b/tests/spiders.py index 3df153a12..cc54240ef 100644 --- a/tests/spiders.py +++ b/tests/spiders.py @@ -1,6 +1,9 @@ """ Some spiders used for testing and benchmarking """ + +from __future__ import annotations + import asyncio import time from urllib.parse import urlencode @@ -77,6 +80,28 @@ class DelaySpider(MetaSpider): self.t2_err = time.time() +class LogSpider(MetaSpider): + name = "log_spider" + + def log_debug(self, message: str, extra: dict | None = None): + self.logger.debug(message, extra=extra) + + def log_info(self, message: str, extra: dict | None = None): + self.logger.info(message, extra=extra) + + def log_warning(self, message: str, extra: dict | None = None): + self.logger.warning(message, extra=extra) + + def log_error(self, message: str, extra: dict | None = None): + self.logger.error(message, extra=extra) + + def log_critical(self, message: str, extra: dict | None = None): + self.logger.critical(message, extra=extra) + + def parse(self, response): + pass + + class SlowSpider(DelaySpider): name = "slow" @@ -259,6 +284,24 @@ class ItemSpider(FollowAllSpider): yield {} +class MaxItemsAndRequestsSpider(FollowAllSpider): + def __init__(self, max_items=10, max_requests=10, *args, **kwargs): + super().__init__(*args, **kwargs) + self.max_items = max_items + self.max_requests = max_requests + + def parse(self, response): + self.items_scraped = 0 + self.pages_crawled = 1 # account for the start url + for request in super().parse(response): + if self.pages_crawled < self.max_requests: + yield request + self.pages_crawled += 1 + if self.items_scraped < self.max_items: + yield Item() + self.items_scraped += 1 + + class DefaultError(Exception): pass @@ -304,6 +347,19 @@ class BrokenStartRequestsSpider(FollowAllSpider): yield from super().parse(response) +class StartRequestsItemSpider(FollowAllSpider): + def start_requests(self): + yield {"name": "test item"} + + +class StartRequestsGoodAndBadOutput(FollowAllSpider): + def start_requests(self): + yield {"a": "a"} + yield Request("data:,a") + yield "data:,b" + yield object() + + class SingleRequestSpider(MetaSpider): seed = None callback_func = None diff --git a/tests/test_addons.py b/tests/test_addons.py index f1b01bc5c..775f629b3 100644 --- a/tests/test_addons.py +++ b/tests/test_addons.py @@ -1,5 +1,5 @@ import itertools -from typing import Any, Dict +from typing import Any from unittest.mock import patch from twisted.internet.defer import inlineCallbacks @@ -17,7 +17,7 @@ class SimpleAddon: pass -def get_addon_cls(config: Dict[str, Any]) -> type: +def get_addon_cls(config: dict[str, Any]) -> type: class AddonWithConfig: def update_settings(self, settings: BaseSettings): settings.update(config, priority="addon") diff --git a/tests/test_closespider.py b/tests/test_closespider.py index 38ede70e4..9a837350f 100644 --- a/tests/test_closespider.py +++ b/tests/test_closespider.py @@ -3,7 +3,13 @@ from twisted.trial.unittest import TestCase from scrapy.utils.test import get_crawler from tests.mockserver import MockServer -from tests.spiders import ErrorSpider, FollowAllSpider, ItemSpider, SlowSpider +from tests.spiders import ( + ErrorSpider, + FollowAllSpider, + ItemSpider, + MaxItemsAndRequestsSpider, + SlowSpider, +) class TestCloseSpider(TestCase): @@ -34,6 +40,43 @@ class TestCloseSpider(TestCase): pagecount = crawler.stats.get_value("response_received_count") self.assertTrue(pagecount >= close_on) + @defer.inlineCallbacks + def test_closespider_pagecount_no_item(self): + close_on = 5 + max_items = 5 + max_requests = close_on + max_items + crawler = get_crawler( + MaxItemsAndRequestsSpider, + { + "CLOSESPIDER_PAGECOUNT_NO_ITEM": close_on, + }, + ) + yield crawler.crawl( + max_items=max_items, max_requests=max_requests, mockserver=self.mockserver + ) + reason = crawler.spider.meta["close_reason"] + self.assertEqual(reason, "closespider_pagecount_no_item") + pagecount = crawler.stats.get_value("response_received_count") + itemcount = crawler.stats.get_value("item_scraped_count") + self.assertLessEqual(pagecount, close_on + itemcount) + + @defer.inlineCallbacks + def test_closespider_pagecount_no_item_with_pagecount(self): + close_on_pagecount_no_item = 5 + close_on_pagecount = 20 + crawler = get_crawler( + FollowAllSpider, + { + "CLOSESPIDER_PAGECOUNT_NO_ITEM": close_on_pagecount_no_item, + "CLOSESPIDER_PAGECOUNT": close_on_pagecount, + }, + ) + yield crawler.crawl(mockserver=self.mockserver) + reason = crawler.spider.meta["close_reason"] + self.assertEqual(reason, "closespider_pagecount_no_item") + pagecount = crawler.stats.get_value("response_received_count") + self.assertLess(pagecount, close_on_pagecount) + @defer.inlineCallbacks def test_closespider_errorcount(self): close_on = 5 diff --git a/tests/test_cmdline/__init__.py b/tests/test_cmdline/__init__.py index 25ded143c..4835e936b 100644 --- a/tests/test_cmdline/__init__.py +++ b/tests/test_cmdline/__init__.py @@ -20,7 +20,7 @@ class CmdlineTest(unittest.TestCase): self.env["SCRAPY_SETTINGS_MODULE"] = "tests.test_cmdline.settings" def _execute(self, *new_args, **kwargs): - encoding = getattr(sys.stdout, "encoding") or "utf-8" + encoding = sys.stdout.encoding or "utf-8" args = (sys.executable, "-m", "scrapy.cmdline") + new_args proc = Popen(args, stdout=PIPE, stderr=PIPE, env=self.env, **kwargs) comm = proc.communicate()[0].strip() diff --git a/tests/test_command_check.py b/tests/test_command_check.py index 592494aba..b0f1cd38a 100644 --- a/tests/test_command_check.py +++ b/tests/test_command_check.py @@ -1,3 +1,8 @@ +import sys +from io import StringIO +from unittest.mock import Mock, PropertyMock, call, patch + +from scrapy.commands.check import Command, TextTestResult from tests.test_commands import CommandTest @@ -94,3 +99,102 @@ class CheckSpider(scrapy.Spider): raise Exception('SCRAPY_CHECK not set') """ self._test_contract(parse_def=parse_def) + + def test_printSummary_with_unsuccessful_test_result_without_errors_and_without_failures( + self, + ): + 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"] + 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): + result = TextTestResult(Mock(), descriptions=False, verbosity=1) + start_time = 1.0 + stop_time = 2.0 + result.testsRun = 5 + result.failures = [(self, "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): + result = TextTestResult(Mock(), descriptions=False, verbosity=1) + start_time = 1.0 + stop_time = 2.0 + result.testsRun = 5 + result.failures = [] + result.errors = [(self, "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, + ): + 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")] + 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): + output = StringIO() + sys.stdout = output + cmd = Command() + cmd.settings = Mock(getwithbase=Mock(return_value={})) + cm_cls_mock.return_value = cm_mock = Mock() + spider_loader_mock = Mock() + cmd.crawler_process = Mock(spider_loader=spider_loader_mock) + spider_name = "FakeSpider" + spider_cls_mock = Mock() + type(spider_cls_mock).name = PropertyMock(return_value=spider_name) + spider_loader_mock.load.side_effect = lambda x: {spider_name: spider_cls_mock}[ + x + ] + tested_methods = ["fakeMethod1", "fakeMethod2"] + cm_mock.tested_methods_from_spidercls.side_effect = lambda x: { + spider_cls_mock: tested_methods + }[x] + + cmd.run([spider_name], Mock(list=True)) + + self.assertEqual( + "FakeSpider\n * fakeMethod1\n * fakeMethod2\n", output.getvalue() + ) + sys.stdout = sys.__stdout__ + + @patch("scrapy.commands.check.ContractsManager") + def test_run_without_opts_list_does_not_crawl_spider_with_no_tested_methods( + self, cm_cls_mock + ): + cmd = Command() + cmd.settings = Mock(getwithbase=Mock(return_value={})) + cm_cls_mock.return_value = cm_mock = Mock() + spider_loader_mock = Mock() + cmd.crawler_process = Mock(spider_loader=spider_loader_mock) + spider_name = "FakeSpider" + spider_cls_mock = Mock() + spider_loader_mock.load.side_effect = lambda x: {spider_name: spider_cls_mock}[ + x + ] + tested_methods = [] + cm_mock.tested_methods_from_spidercls.side_effect = lambda x: { + spider_cls_mock: tested_methods + }[x] + + cmd.run([spider_name], Mock(list=False)) + + cmd.crawler_process.crawl.assert_not_called() diff --git a/tests/test_command_version.py b/tests/test_command_version.py index a52d0d13c..18c1c531c 100644 --- a/tests/test_command_version.py +++ b/tests/test_command_version.py @@ -12,7 +12,7 @@ class VersionTest(ProcessTest, unittest.TestCase): @defer.inlineCallbacks def test_output(self): - encoding = getattr(sys.stdout, "encoding") or "utf-8" + encoding = sys.stdout.encoding or "utf-8" _, out, _ = yield self.execute([]) self.assertEqual( out.strip().decode(encoding), @@ -21,7 +21,7 @@ class VersionTest(ProcessTest, unittest.TestCase): @defer.inlineCallbacks def test_verbose_output(self): - encoding = getattr(sys.stdout, "encoding") or "utf-8" + encoding = sys.stdout.encoding or "utf-8" _, out, _ = yield self.execute(["-v"]) headers = [ line.partition(":")[0].strip() diff --git a/tests/test_commands.py b/tests/test_commands.py index 36f800850..e7df7b6e8 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import argparse import inspect import json @@ -6,15 +8,14 @@ import platform import re import subprocess import sys -import tempfile from contextlib import contextmanager from itertools import chain from pathlib import Path from shutil import copytree, rmtree from stat import S_IWRITE as ANYONE_WRITE_PERMISSION -from tempfile import mkdtemp +from tempfile import TemporaryFile, mkdtemp from threading import Timer -from typing import Dict, Generator, Optional, Union +from typing import TYPE_CHECKING from unittest import skipIf from pytest import mark @@ -28,6 +29,9 @@ from scrapy.utils.python import to_unicode from scrapy.utils.test import get_testenv from tests.test_crawler import ExceptionSpider, NoRequestsSpider +if TYPE_CHECKING: + from collections.abc import Iterator + class CommandSettings(unittest.TestCase): def setUp(self): @@ -82,7 +86,7 @@ class ProjectTest(unittest.TestCase): rmtree(self.temp_path) def call(self, *new_args, **kwargs): - with tempfile.TemporaryFile() as out: + with TemporaryFile() as out: args = (sys.executable, "-m", "scrapy.cmdline") + new_args return subprocess.call( args, stdout=out, stderr=out, cwd=self.cwd, env=self.env, **kwargs @@ -102,7 +106,7 @@ class ProjectTest(unittest.TestCase): def kill_proc(): p.kill() p.communicate() - assert False, "Command took too much time to complete" + raise AssertionError("Command took too much time to complete") timer = Timer(15, kill_proc) try: @@ -113,9 +117,7 @@ class ProjectTest(unittest.TestCase): return p, to_unicode(stdout), to_unicode(stderr) - def find_in_file( - self, filename: Union[str, os.PathLike], regex - ) -> Optional[re.Match]: + 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: @@ -194,14 +196,14 @@ class StartprojectTest(ProjectTest): def get_permissions_dict( - path: Union[str, os.PathLike], renamings=None, ignore=None -) -> Dict[str, str]: + path: str | os.PathLike, renamings=None, ignore=None +) -> dict[str, str]: def get_permissions(path: Path) -> str: return oct(path.stat().st_mode) path_obj = Path(path) - renamings = renamings or tuple() + renamings = renamings or () permissions_dict = { ".": get_permissions(path_obj), } @@ -675,7 +677,7 @@ class BadSpider(scrapy.Spider): """ @contextmanager - def _create_file(self, content, name=None) -> Generator[str, None, None]: + def _create_file(self, content, name=None) -> Iterator[str]: tmpdir = Path(self.mktemp()) tmpdir.mkdir() if name: @@ -988,42 +990,19 @@ class MySpider(scrapy.Spider): self.assertIn("The value of FOO is 42", log) -@skipIf(platform.system() != "Windows", "Windows required for .pyw files") class WindowsRunSpiderCommandTest(RunSpiderCommandTest): spider_filename = "myspider.pyw" def setUp(self): - super().setUp() + if platform.system() != "Windows": + raise unittest.SkipTest("Windows required for .pyw files") + return super().setUp() def test_start_requests_errors(self): log = self.get_log(self.badspider, name="badspider.pyw") self.assertIn("start_requests", log) self.assertIn("badspider.pyw", log) - def test_run_good_spider(self): - super().test_run_good_spider() - - def test_runspider(self): - super().test_runspider() - - def test_runspider_dnscache_disabled(self): - super().test_runspider_dnscache_disabled() - - def test_runspider_log_level(self): - super().test_runspider_log_level() - - def test_runspider_log_short_names(self): - super().test_runspider_log_short_names() - - def test_runspider_no_spider_found(self): - super().test_runspider_no_spider_found() - - def test_output(self): - super().test_output() - - def test_overwrite_output(self): - super().test_overwrite_output() - def test_runspider_unable_to_load(self): raise unittest.SkipTest("Already Tested in 'RunSpiderCommandTest' ") diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 1459e0b5f..d578b3af4 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -8,6 +8,7 @@ from scrapy import FormRequest from scrapy.contracts import Contract, ContractsManager from scrapy.contracts.default import ( CallbackKeywordArgumentsContract, + MetadataContract, ReturnsContract, ScrapesContract, UrlContract, @@ -29,6 +30,10 @@ class ResponseMock: url = "http://scrapy.org" +class ResponseMetaMock(ResponseMock): + meta = None + + class CustomSuccessContract(Contract): name = "custom_success_contract" @@ -182,6 +187,46 @@ class TestSpider(Spider): """ pass + def invalid_regex(self, response): + """method with invalid regex + @ Scrapy is awsome + """ + pass + + def invalid_regex_with_valid_contract(self, response): + """method with invalid regex + @ scrapy is awsome + @url http://scrapy.org + """ + pass + + def returns_request_meta(self, response): + """method which returns request + @url https://example.org + @meta {"cookiejar": "session1"} + @returns requests 1 + """ + return Request( + "https://example.org", meta=response.meta, callback=self.returns_item_meta + ) + + def returns_item_meta(self, response): + """method which returns item + @url http://scrapy.org + @meta {"key": "example"} + @returns items 1 1 + """ + return TestItem(name="example", url=response.url) + + def returns_error_missing_meta(self, response): + """method which depends of metadata be defined + + @url http://scrapy.org + @returns items 1 + """ + key = response.meta["key"] + yield {key: "value"} + class CustomContractSuccessSpider(Spider): name = "custom_contract_success_spider" @@ -211,6 +256,7 @@ class ContractsManagerTest(unittest.TestCase): contracts = [ UrlContract, CallbackKeywordArgumentsContract, + MetadataContract, ReturnsContract, ScrapesContract, CustomFormContract, @@ -315,6 +361,52 @@ class ContractsManagerTest(unittest.TestCase): request.callback(response, **request.cb_kwargs) self.should_error() + def test_meta(self): + spider = TestSpider() + + # extract contracts correctly + contracts = self.conman.extract_contracts(spider.returns_request_meta) + self.assertEqual(len(contracts), 3) + self.assertEqual( + frozenset(type(x) for x in contracts), + frozenset([UrlContract, MetadataContract, ReturnsContract]), + ) + + contracts = self.conman.extract_contracts(spider.returns_item_meta) + self.assertEqual(len(contracts), 3) + self.assertEqual( + frozenset(type(x) for x in contracts), + frozenset([UrlContract, MetadataContract, ReturnsContract]), + ) + + response = ResponseMetaMock() + + # returns_request + request = self.conman.from_method(spider.returns_request_meta, self.results) + assert request.meta["cookiejar"] == "session1" + response.meta = request.meta + request.callback(response) + assert response.meta["cookiejar"] == "session1" + self.should_succeed() + + response = ResponseMetaMock() + + # returns_item + request = self.conman.from_method(spider.returns_item_meta, self.results) + assert request.meta["key"] == "example" + response.meta = request.meta + request.callback(ResponseMetaMock) + assert response.meta["key"] == "example" + self.should_succeed() + + response = ResponseMetaMock() + + request = self.conman.from_method( + spider.returns_error_missing_meta, self.results + ) + request.callback(response) + self.should_error() + def test_returns(self): spider = TestSpider() response = ResponseMock() @@ -385,6 +477,21 @@ class ContractsManagerTest(unittest.TestCase): message = "ContractFail: Missing fields: name, url" assert message in self.results.failures[-1][-1] + def test_regex(self): + spider = TestSpider() + response = ResponseMock() + + # invalid regex + request = self.conman.from_method(spider.invalid_regex, self.results) + self.should_succeed() + + # invalid regex with valid contract + request = self.conman.from_method( + spider.invalid_regex_with_valid_contract, self.results + ) + self.should_succeed() + request.callback(response) + def test_custom_contracts(self): self.conman.from_spider(CustomContractSuccessSpider(), self.results) self.should_succeed() diff --git a/tests/test_core_downloader.py b/tests/test_core_downloader.py index 9a6e9e4ff..81cff4947 100644 --- a/tests/test_core_downloader.py +++ b/tests/test_core_downloader.py @@ -7,5 +7,6 @@ class SlotTest(unittest.TestCase): def test_repr(self): slot = Slot(concurrency=8, delay=0.1, randomize_delay=True) self.assertEqual( - repr(slot), "Slot(concurrency=8, delay=0.10, randomize_delay=True)" + repr(slot), + "Slot(concurrency=8, delay=0.10, randomize_delay=True, throttle=None)", ) diff --git a/tests/test_crawl.py b/tests/test_crawl.py index 96d43b2b9..125709571 100644 --- a/tests/test_crawl.py +++ b/tests/test_crawl.py @@ -1,5 +1,6 @@ import json import logging +import re import unittest from ipaddress import IPv4Address from socket import gethostbyname @@ -49,6 +50,8 @@ from tests.spiders import ( HeadersReceivedErrbackSpider, SimpleSpider, SingleRequestSpider, + StartRequestsGoodAndBadOutput, + StartRequestsItemSpider, ) @@ -76,11 +79,11 @@ class CrawlTestCase(TestCase): @defer.inlineCallbacks def _test_delay(self, total, delay, randomize=False): - crawl_kwargs = dict( - maxlatency=delay * 2, - mockserver=self.mockserver, - total=total, - ) + crawl_kwargs = { + "maxlatency": delay * 2, + "mockserver": self.mockserver, + "total": total, + } tolerance = 1 - (0.6 if randomize else 0.2) settings = {"DOWNLOAD_DELAY": delay, "RANDOMIZE_DOWNLOAD_DELAY": randomize} @@ -184,6 +187,39 @@ class CrawlTestCase(TestCase): self.assertIsNotNone(record.exc_info) self.assertIs(record.exc_info[0], ZeroDivisionError) + @defer.inlineCallbacks + def test_start_requests_items(self): + with LogCapture("scrapy", level=logging.ERROR) as log: + crawler = get_crawler(StartRequestsItemSpider) + yield crawler.crawl(mockserver=self.mockserver) + + self.assertEqual(len(log.records), 0) + + @defer.inlineCallbacks + def test_start_requests_unsupported_output(self): + with LogCapture("scrapy", level=logging.ERROR) as log: + crawler = get_crawler(StartRequestsGoodAndBadOutput) + yield crawler.crawl(mockserver=self.mockserver) + + self.assertEqual(len(log.records), 2) + self.assertEqual( + log.records[0].msg, + ( + "Got 'data:,b' among start requests. Only requests and items " + "are supported. It will be ignored." + ), + ) + self.assertTrue( + re.match( + ( + r"^Got among start " + r"requests\. Only requests and items are supported\. It " + r"will be ignored\.$" + ), + log.records[1].msg, + ) + ) + @defer.inlineCallbacks def test_start_requests_laziness(self): settings = {"CONCURRENT_REQUESTS": 1} diff --git a/tests/test_crawler.py b/tests/test_crawler.py index 989208694..69bfb7eb3 100644 --- a/tests/test_crawler.py +++ b/tests/test_crawler.py @@ -6,7 +6,6 @@ import subprocess import sys import warnings from pathlib import Path -from typing import List import pytest from packaging.version import parse as parse_version @@ -21,7 +20,6 @@ import scrapy from scrapy import Spider from scrapy.crawler import Crawler, CrawlerProcess, CrawlerRunner from scrapy.exceptions import ScrapyDeprecationWarning -from scrapy.extensions import telnet from scrapy.extensions.throttle import AutoThrottle from scrapy.settings import Settings, default_settings from scrapy.spiderloader import SpiderLoader @@ -482,7 +480,6 @@ class CrawlerLoggingTestCase(unittest.TestCase): "LOG_FILE": str(log_file), # settings to avoid extra warnings "REQUEST_FINGERPRINTER_IMPLEMENTATION": "2.7", - "TELNETCONSOLE_ENABLED": telnet.TWISTED_CONCH_AVAILABLE, } configure_logging() @@ -516,8 +513,6 @@ class CrawlerLoggingTestCase(unittest.TestCase): custom_settings = { "LOG_FILE": str(log_file), "LOG_FILE_APPEND": False, - # disable telnet if not available to avoid an extra warning - "TELNETCONSOLE_ENABLED": telnet.TWISTED_CONCH_AVAILABLE, } configure_logging() @@ -655,7 +650,7 @@ class ScriptRunnerMixin: script_dir: Path cwd = os.getcwd() - def get_script_args(self, script_name: str, *script_args: str) -> List[str]: + def get_script_args(self, script_name: str, *script_args: str) -> list[str]: script_path = self.script_dir / script_name return [sys.executable, str(script_path)] + list(script_args) @@ -926,3 +921,11 @@ class CrawlerRunnerSubprocess(ScriptRunnerMixin, unittest.TestCase): self.assertIn("INFO: Host: not.a.real.domain", log) self.assertIn("INFO: Type: ", log) self.assertIn("INFO: IP address: 127.0.0.1", log) + + def test_change_default_reactor(self): + log = self.run_script("change_reactor.py") + self.assertIn( + "DEBUG: Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor", + log, + ) + self.assertIn("DEBUG: Using asyncio event loop", log) diff --git a/tests/test_downloader_handlers.py b/tests/test_downloader_handlers.py index dd07d33f1..19cea97ec 100644 --- a/tests/test_downloader_handlers.py +++ b/tests/test_downloader_handlers.py @@ -1,10 +1,11 @@ +from __future__ import annotations + import contextlib import os import shutil import sys -import tempfile from pathlib import Path -from typing import Optional, Type +from tempfile import mkdtemp, mkstemp from unittest import SkipTest, mock from testfixtures import LogCapture @@ -107,13 +108,14 @@ class LoadTestCase(unittest.TestCase): class FileTestCase(unittest.TestCase): def setUp(self): # add a special char to check that they are handled correctly - self.tmpname = Path(self.mktemp() + "^") + self.fd, self.tmpname = mkstemp(suffix="^") Path(self.tmpname).write_text("0123456789", encoding="utf-8") handler = build_from_crawler(FileDownloadHandler, get_crawler()) self.download_request = handler.download_request def tearDown(self): - self.tmpname.unlink() + os.close(self.fd) + os.remove(self.tmpname) def test_download(self): def _test(response): @@ -122,12 +124,12 @@ class FileTestCase(unittest.TestCase): self.assertEqual(response.body, b"0123456789") self.assertEqual(response.protocol, None) - request = Request(path_to_file_uri(str(self.tmpname))) + request = Request(path_to_file_uri(self.tmpname)) assert request.url.upper().endswith("%5E") return self.download_request(request, Spider("foo")).addCallback(_test) def test_non_existent(self): - request = Request(path_to_file_uri(self.mktemp())) + request = Request(path_to_file_uri(mkdtemp())) d = self.download_request(request, Spider("foo")) return self.assertFailure(d, OSError) @@ -217,15 +219,14 @@ class DuplicateHeaderResource(resource.Resource): class HttpTestCase(unittest.TestCase): scheme = "http" - download_handler_cls: Type = HTTPDownloadHandler + download_handler_cls: type = HTTPDownloadHandler # only used for HTTPS tests keyfile = "keys/localhost.key" certfile = "keys/localhost.crt" def setUp(self): - self.tmpname = Path(self.mktemp()) - self.tmpname.mkdir() + 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")) @@ -428,7 +429,7 @@ class HttpTestCase(unittest.TestCase): class Http10TestCase(HttpTestCase): """HTTP 1.0 test case""" - download_handler_cls: Type = HTTP10DownloadHandler + download_handler_cls: type = HTTP10DownloadHandler def test_protocol(self): request = Request(self.getURL("host"), method="GET") @@ -445,7 +446,7 @@ class Https10TestCase(Http10TestCase): class Http11TestCase(HttpTestCase): """HTTP 1.1 test case""" - download_handler_cls: Type = HTTP11DownloadHandler + download_handler_cls: type = HTTP11DownloadHandler def test_download_without_maxsize_limit(self): request = Request(self.getURL("file")) @@ -645,14 +646,13 @@ class Https11InvalidDNSPattern(Https11TestCase): class Https11CustomCiphers(unittest.TestCase): scheme = "https" - download_handler_cls: Type = HTTP11DownloadHandler + download_handler_cls: type = HTTP11DownloadHandler keyfile = "keys/localhost.key" certfile = "keys/localhost.crt" def setUp(self): - self.tmpname = Path(self.mktemp()) - self.tmpname.mkdir() + self.tmpname = Path(mkdtemp()) (self.tmpname / "file").write_bytes(b"0123456789") r = static.File(str(self.tmpname)) self.site = server.Site(r, timeout=None) @@ -693,7 +693,7 @@ class Https11CustomCiphers(unittest.TestCase): class Http11MockServerTestCase(unittest.TestCase): """HTTP 1.1 test case with MockServer""" - settings_dict: Optional[dict] = None + settings_dict: dict | None = None def setUp(self): self.mockserver = MockServer() @@ -741,7 +741,7 @@ class UriResource(resource.Resource): class HttpProxyTestCase(unittest.TestCase): - download_handler_cls: Type = HTTPDownloadHandler + download_handler_cls: type = HTTPDownloadHandler expected_http_proxy_request_body = b"http://example.com" def setUp(self): @@ -784,14 +784,14 @@ class HttpProxyTestCase(unittest.TestCase): class Http10ProxyTestCase(HttpProxyTestCase): - download_handler_cls: Type = HTTP10DownloadHandler + download_handler_cls: type = HTTP10DownloadHandler def test_download_with_proxy_https_noconnect(self): raise unittest.SkipTest("noconnect is not supported in HTTP10DownloadHandler") class Http11ProxyTestCase(HttpProxyTestCase): - download_handler_cls: Type = HTTP11DownloadHandler + download_handler_cls: type = HTTP11DownloadHandler @defer.inlineCallbacks def test_download_with_proxy_https_timeout(self): @@ -846,7 +846,7 @@ class S3AnonTestCase(unittest.TestCase): class S3TestCase(unittest.TestCase): - download_handler_cls: Type = S3DownloadHandler + download_handler_cls: type = S3DownloadHandler # test use same example keys than amazon developer guide # http://s3.amazonaws.com/awsdocs/S3/20060301/s3-dg-20060301.pdf @@ -893,7 +893,7 @@ class S3TestCase(unittest.TestCase): except Exception as e: self.assertIsInstance(e, (TypeError, NotConfigured)) else: - assert False + raise AssertionError() def test_request_signing1(self): # gets an object from the johnsmith bucket. @@ -1015,8 +1015,7 @@ class BaseFTPTestCase(unittest.TestCase): from scrapy.core.downloader.handlers.ftp import FTPDownloadHandler # setup dirs and test file - self.directory = Path(self.mktemp()) - self.directory.mkdir() + self.directory = Path(mkdtemp()) userdir = self.directory / self.username userdir.mkdir() for filename, content in self.test_files: @@ -1092,7 +1091,7 @@ class BaseFTPTestCase(unittest.TestCase): return self._add_test_callbacks(d, _test) def test_ftp_local_filename(self): - f, local_fname = tempfile.mkstemp() + f, local_fname = mkstemp() fname_bytes = to_bytes(local_fname) local_fname = Path(local_fname) os.close(f) @@ -1113,7 +1112,7 @@ class BaseFTPTestCase(unittest.TestCase): return self._add_test_callbacks(d, _test) def _test_response_class(self, filename, response_class): - f, local_fname = tempfile.mkstemp() + f, local_fname = mkstemp() local_fname = Path(local_fname) os.close(f) meta = {} @@ -1163,9 +1162,7 @@ class AnonymousFTPTestCase(BaseFTPTestCase): from scrapy.core.downloader.handlers.ftp import FTPDownloadHandler # setup dir and test file - self.directory = Path(self.mktemp()) - self.directory.mkdir() - + self.directory = Path(mkdtemp()) for filename, content in self.test_files: (self.directory / filename).write_bytes(content) diff --git a/tests/test_downloadermiddleware.py b/tests/test_downloadermiddleware.py index 062e8a8b4..dd3f8ceb9 100644 --- a/tests/test_downloadermiddleware.py +++ b/tests/test_downloadermiddleware.py @@ -22,13 +22,11 @@ class ManagerTestCase(TestCase): self.crawler = get_crawler(Spider, self.settings_dict) self.spider = self.crawler._create_spider("foo") self.mwman = DownloaderMiddlewareManager.from_crawler(self.crawler) - # some mw depends on stats collector - self.crawler.stats.open_spider(self.spider) - return self.mwman.open_spider(self.spider) + self.crawler.engine = self.crawler._create_engine() + return self.crawler.engine.open_spider(self.spider, start_requests=()) def tearDown(self): - self.crawler.stats.close_spider(self.spider, "") - return self.mwman.close_spider(self.spider) + return self.crawler.engine.close_spider(self.spider) def _download(self, request, response=None): """Executes downloader mw manager's download method and returns @@ -38,7 +36,7 @@ class ManagerTestCase(TestCase): if not response: response = Response(request.url) - def download_func(**kwargs): + def download_func(request, spider): return response dfd = self.mwman.download(download_func, request, self.spider) diff --git a/tests/test_downloadermiddleware_cookies.py b/tests/test_downloadermiddleware_cookies.py index 4a81a638e..6e343d035 100644 --- a/tests/test_downloadermiddleware_cookies.py +++ b/tests/test_downloadermiddleware_cookies.py @@ -14,6 +14,8 @@ from scrapy.spiders import Spider from scrapy.utils.python import to_bytes from scrapy.utils.test import get_crawler +UNSET = object() + def _cookie_to_set_cookie_value(cookie): """Given a cookie defined as a dictionary with name and value keys, and @@ -320,7 +322,7 @@ class CookiesMiddlewareTest(TestCase): @pytest.mark.xfail(reason="Cookie header is not currently being processed") def test_keep_cookie_from_default_request_headers_middleware(self): - DEFAULT_REQUEST_HEADERS = dict(Cookie="default=value; asdf=qwerty") + DEFAULT_REQUEST_HEADERS = {"Cookie": "default=value; asdf=qwerty"} mw_default_headers = DefaultHeadersMiddleware(DEFAULT_REQUEST_HEADERS.items()) # overwrite with values from 'cookies' request argument req1 = Request("http://example.org", cookies={"default": "something"}) @@ -360,7 +362,7 @@ class CookiesMiddlewareTest(TestCase): def test_request_cookies_encoding(self): # 1) UTF8-encoded bytes - req1 = Request("http://example.org", cookies={"a": "á".encode("utf8")}) + req1 = Request("http://example.org", cookies={"a": "á".encode()}) assert self.mw.process_request(req1, self.spider) is None self.assertCookieValEqual(req1.headers["Cookie"], b"a=\xc3\xa1") @@ -377,7 +379,7 @@ class CookiesMiddlewareTest(TestCase): @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("utf8")}) + req1 = Request("http://example.org", headers={"Cookie": "a=á".encode()}) assert self.mw.process_request(req1, self.spider) is None self.assertCookieValEqual(req1.headers["Cookie"], b"a=\xc3\xa1") @@ -414,19 +416,19 @@ class CookiesMiddlewareTest(TestCase): "scrapy.downloadermiddlewares.cookies", "WARNING", "Invalid cookie found in request :" - " {'value': 'bar'} ('name' is missing)", + " {'value': 'bar', 'secure': False} ('name' is missing)", ), ( "scrapy.downloadermiddlewares.cookies", "WARNING", "Invalid cookie found in request :" - " {'name': 'foo'} ('value' is missing)", + " {'name': 'foo', 'secure': False} ('value' is missing)", ), ( "scrapy.downloadermiddlewares.cookies", "WARNING", "Invalid cookie found in request :" - " {'name': 'foo', 'value': None} ('value' is missing)", + " {'name': 'foo', 'value': None, 'secure': False} ('value' is missing)", ), ) self.assertCookieValEqual(req1.headers["Cookie"], "key=value1") @@ -732,3 +734,106 @@ class CookiesMiddlewareTest(TestCase): "co.uk", cookies=True, ) + + def _test_cookie_redirect_scheme_change( + self, secure, from_scheme, to_scheme, cookies1, cookies2, cookies3 + ): + """When a redirect causes the URL scheme to change from *from_scheme* + to *to_scheme*, while domain and port remain the same, and given a + cookie on the initial request with its secure attribute set to + *secure*, check if the cookie should be set on the Cookie header of the + initial request (*cookies1*), if it should be kept by the redirect + middleware (*cookies2*), and if it should be present on the Cookie + header in the redirected request (*cookie3*).""" + cookie_kwargs = {} + if secure is not UNSET: + cookie_kwargs["secure"] = secure + 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) + cookies = request1.headers.get("Cookie") + self.assertEqual(cookies, b"a=b" if cookies1 else None) + + response = Response( + f"{from_scheme}://a.example", + headers={"Location": f"{to_scheme}://a.example"}, + status=301, + ) + self.assertEqual( + self.mw.process_response(request1, response, self.spider), + response, + ) + + request2 = self.redirect_middleware.process_response( + request1, + response, + self.spider, + ) + self.assertIsInstance(request2, Request) + cookies = request2.headers.get("Cookie") + self.assertEqual(cookies, b"a=b" if cookies2 else None) + + self.mw.process_request(request2, self.spider) + cookies = request2.headers.get("Cookie") + self.assertEqual(cookies, b"a=b" if cookies3 else None) + + def test_cookie_redirect_secure_undefined_downgrade(self): + self._test_cookie_redirect_scheme_change( + secure=UNSET, + from_scheme="https", + to_scheme="http", + cookies1=True, + cookies2=False, + cookies3=False, + ) + + def test_cookie_redirect_secure_undefined_upgrade(self): + self._test_cookie_redirect_scheme_change( + secure=UNSET, + from_scheme="http", + to_scheme="https", + cookies1=True, + cookies2=True, + cookies3=True, + ) + + def test_cookie_redirect_secure_false_downgrade(self): + self._test_cookie_redirect_scheme_change( + secure=False, + from_scheme="https", + to_scheme="http", + cookies1=True, + cookies2=False, + cookies3=True, + ) + + def test_cookie_redirect_secure_false_upgrade(self): + self._test_cookie_redirect_scheme_change( + secure=False, + from_scheme="http", + to_scheme="https", + cookies1=True, + cookies2=True, + cookies3=True, + ) + + def test_cookie_redirect_secure_true_downgrade(self): + self._test_cookie_redirect_scheme_change( + secure=True, + from_scheme="https", + to_scheme="http", + cookies1=True, + cookies2=False, + cookies3=False, + ) + + def test_cookie_redirect_secure_true_upgrade(self): + self._test_cookie_redirect_scheme_change( + secure=True, + from_scheme="http", + to_scheme="https", + cookies1=False, + cookies2=False, + cookies3=True, + ) diff --git a/tests/test_downloadermiddleware_httpauth.py b/tests/test_downloadermiddleware_httpauth.py index fc110e6cc..500af6536 100644 --- a/tests/test_downloadermiddleware_httpauth.py +++ b/tests/test_downloadermiddleware_httpauth.py @@ -59,7 +59,7 @@ class HttpAuthMiddlewareTest(unittest.TestCase): self.assertEqual(req.headers["Authorization"], basic_auth_header("foo", "bar")) def test_auth_already_set(self): - req = Request("http://example.com/", headers=dict(Authorization="Digest 123")) + req = Request("http://example.com/", headers={"Authorization": "Digest 123"}) assert self.mw.process_request(req, self.spider) is None self.assertEqual(req.headers["Authorization"], b"Digest 123") @@ -79,6 +79,6 @@ class HttpAuthAnyMiddlewareTest(unittest.TestCase): self.assertEqual(req.headers["Authorization"], basic_auth_header("foo", "bar")) def test_auth_already_set(self): - req = Request("http://example.com/", headers=dict(Authorization="Digest 123")) + req = Request("http://example.com/", headers={"Authorization": "Digest 123"}) assert self.mw.process_request(req, self.spider) is None self.assertEqual(req.headers["Authorization"], b"Digest 123") diff --git a/tests/test_downloadermiddleware_httpcompression.py b/tests/test_downloadermiddleware_httpcompression.py index a96b710f3..7c36f748e 100644 --- a/tests/test_downloadermiddleware_httpcompression.py +++ b/tests/test_downloadermiddleware_httpcompression.py @@ -1,15 +1,18 @@ from gzip import GzipFile from io import BytesIO +from logging import WARNING from pathlib import Path from unittest import SkipTest, TestCase +from warnings import catch_warnings +from testfixtures import LogCapture from w3lib.encoding import resolve_encoding from scrapy.downloadermiddlewares.httpcompression import ( ACCEPTED_ENCODINGS, HttpCompressionMiddleware, ) -from scrapy.exceptions import NotConfigured +from scrapy.exceptions import IgnoreRequest, NotConfigured, ScrapyDeprecationWarning from scrapy.http import HtmlResponse, Request, Response from scrapy.responsetypes import responsetypes from scrapy.spiders import Spider @@ -24,6 +27,8 @@ FORMAT = { "x-gzip": ("html-gzip.bin", "gzip"), "rawdeflate": ("html-rawdeflate.bin", "deflate"), "zlibdeflate": ("html-zlibdeflate.bin", "deflate"), + "gzip-deflate": ("html-gzip-deflate.bin", "gzip, deflate"), + "gzip-deflate-gzip": ("html-gzip-deflate-gzip.bin", "gzip, deflate, gzip"), "br": ("html-br.bin", "br"), # $ zstd raw.html --content-size -o html-zstd-static-content-size.bin "zstd-static-content-size": ("html-zstd-static-content-size.bin", "zstd"), @@ -34,6 +39,15 @@ FORMAT = { "html-zstd-streaming-no-content-size.bin", "zstd", ), + **{ + f"bomb-{format_id}": (f"bomb-{format_id}.bin", format_id) + for format_id in ( + "br", # 34 → 11 511 612 + "deflate", # 27 968 → 11 511 612 + "gzip", # 27 988 → 11 511 612 + "zstd", # 1 096 → 11 511 612 + ) + }, } @@ -114,21 +128,12 @@ class HttpCompressionTest(TestCase): self.assertStatsEqual("httpcompression/response_count", 1) self.assertStatsEqual("httpcompression/response_bytes", 74837) - def test_process_response_gzip_no_stats(self): - mw = HttpCompressionMiddleware() - response = self._getresponse("gzip") - request = response.request - - self.assertEqual(response.headers["Content-Encoding"], b"gzip") - newresponse = mw.process_response(request, response, self.spider) - self.assertEqual(mw.stats, None) - assert newresponse is not response - assert newresponse.body.startswith(b" body size after " + "decompression (11511612 B) is larger than the download " + "warning size (10000000 B)." + ), + ), + ) + + def test_download_warnsize_setting_br(self): + try: + try: + import brotli # noqa: F401 + except ImportError: + import brotlicffi # noqa: F401 + except ImportError: + raise SkipTest("no brotli") + self._test_download_warnsize_setting("br") + + def test_download_warnsize_setting_deflate(self): + self._test_download_warnsize_setting("deflate") + + def test_download_warnsize_setting_gzip(self): + self._test_download_warnsize_setting("gzip") + + def test_download_warnsize_setting_zstd(self): + try: + import zstandard # noqa: F401 + except ImportError: + raise SkipTest("no zstd support (zstandard)") + self._test_download_warnsize_setting("zstd") + + def _test_download_warnsize_spider_attr(self, compression_id): + class DownloadWarnSizeSpider(Spider): + download_warnsize = 10_000_000 + + crawler = get_crawler(DownloadWarnSizeSpider) + spider = crawler._create_spider("scrapytest.org") + mw = HttpCompressionMiddleware.from_crawler(crawler) + mw.open_spider(spider) + response = self._getresponse(f"bomb-{compression_id}") + + with LogCapture( + "scrapy.downloadermiddlewares.httpcompression", + propagate=False, + level=WARNING, + ) as log: + mw.process_response(response.request, response, spider) + log.check( + ( + "scrapy.downloadermiddlewares.httpcompression", + "WARNING", + ( + "<200 http://scrapytest.org/> body size after " + "decompression (11511612 B) is larger than the download " + "warning size (10000000 B)." + ), + ), + ) + + def test_download_warnsize_spider_attr_br(self): + try: + try: + import brotli # noqa: F401 + except ImportError: + import brotlicffi # noqa: F401 + except ImportError: + raise SkipTest("no brotli") + self._test_download_warnsize_spider_attr("br") + + def test_download_warnsize_spider_attr_deflate(self): + self._test_download_warnsize_spider_attr("deflate") + + def test_download_warnsize_spider_attr_gzip(self): + self._test_download_warnsize_spider_attr("gzip") + + def test_download_warnsize_spider_attr_zstd(self): + try: + import zstandard # noqa: F401 + except ImportError: + raise SkipTest("no zstd support (zstandard)") + self._test_download_warnsize_spider_attr("zstd") + + def _test_download_warnsize_request_meta(self, compression_id): + crawler = get_crawler(Spider) + spider = crawler._create_spider("scrapytest.org") + mw = HttpCompressionMiddleware.from_crawler(crawler) + mw.open_spider(spider) + response = self._getresponse(f"bomb-{compression_id}") + response.meta["download_warnsize"] = 10_000_000 + + with LogCapture( + "scrapy.downloadermiddlewares.httpcompression", + propagate=False, + level=WARNING, + ) as log: + mw.process_response(response.request, response, spider) + log.check( + ( + "scrapy.downloadermiddlewares.httpcompression", + "WARNING", + ( + "<200 http://scrapytest.org/> body size after " + "decompression (11511612 B) is larger than the download " + "warning size (10000000 B)." + ), + ), + ) + + def test_download_warnsize_request_meta_br(self): + try: + try: + import brotli # noqa: F401 + except ImportError: + import brotlicffi # noqa: F401 + except ImportError: + raise SkipTest("no brotli") + self._test_download_warnsize_request_meta("br") + + def test_download_warnsize_request_meta_deflate(self): + self._test_download_warnsize_request_meta("deflate") + + def test_download_warnsize_request_meta_gzip(self): + self._test_download_warnsize_request_meta("gzip") + + def test_download_warnsize_request_meta_zstd(self): + try: + import zstandard # noqa: F401 + except ImportError: + raise SkipTest("no zstd support (zstandard)") + self._test_download_warnsize_request_meta("zstd") + + +class HttpCompressionSubclassTest(TestCase): + def test_init_missing_stats(self): + class HttpCompressionMiddlewareSubclass(HttpCompressionMiddleware): + def __init__(self): + super().__init__() + + crawler = get_crawler(Spider) + with catch_warnings(record=True) as caught_warnings: + HttpCompressionMiddlewareSubclass.from_crawler(crawler) + messages = tuple( + str(warning.message) + for warning in caught_warnings + if warning.category is ScrapyDeprecationWarning + ) + self.assertEqual( + messages, + ( + ( + "HttpCompressionMiddleware subclasses must either modify " + "their '__init__' method to support a 'crawler' parameter " + "or reimplement their 'from_crawler' method." + ), + ), + ) diff --git a/tests/test_downloadermiddleware_offsite.py b/tests/test_downloadermiddleware_offsite.py new file mode 100644 index 000000000..d4669f450 --- /dev/null +++ b/tests/test_downloadermiddleware_offsite.py @@ -0,0 +1,184 @@ +import pytest + +from scrapy import Request, Spider +from scrapy.downloadermiddlewares.offsite import OffsiteMiddleware +from scrapy.exceptions import IgnoreRequest +from scrapy.utils.test import get_crawler + +UNSET = object() + + +@pytest.mark.parametrize( + ("allowed_domain", "url", "allowed"), + ( + ("example.com", "http://example.com/1", True), + ("example.com", "http://example.org/1", False), + ("example.com", "http://sub.example.com/1", True), + ("sub.example.com", "http://sub.example.com/1", True), + ("sub.example.com", "http://example.com/1", False), + ("example.com", "http://example.com:8000/1", True), + ("example.com", "http://example.org/example.com", False), + ("example.com", "http://example.org/foo.example.com", False), + ("example.com", "http://example.com.example", False), + ("a.example", "http://nota.example", False), + ("b.a.example", "http://notb.a.example", False), + ), +) +def test_process_request_domain_filtering(allowed_domain, url, allowed): + crawler = get_crawler(Spider) + spider = crawler._create_spider(name="a", allowed_domains=[allowed_domain]) + mw = OffsiteMiddleware.from_crawler(crawler) + mw.spider_opened(spider) + request = Request(url) + if allowed: + assert mw.process_request(request, spider) is None + else: + with pytest.raises(IgnoreRequest): + mw.process_request(request, spider) + + +@pytest.mark.parametrize( + ("value", "filtered"), + ( + (UNSET, True), + (None, True), + (False, True), + (True, False), + ), +) +def test_process_request_dont_filter(value, filtered): + crawler = get_crawler(Spider) + spider = crawler._create_spider(name="a", allowed_domains=["a.example"]) + mw = OffsiteMiddleware.from_crawler(crawler) + mw.spider_opened(spider) + kwargs = {} + if value is not UNSET: + kwargs["dont_filter"] = value + request = Request("https://b.example", **kwargs) + if filtered: + with pytest.raises(IgnoreRequest): + mw.process_request(request, spider) + else: + assert mw.process_request(request, spider) is None + + +@pytest.mark.parametrize( + "value", + ( + UNSET, + None, + [], + ), +) +def test_process_request_no_allowed_domains(value): + crawler = get_crawler(Spider) + kwargs = {} + if value is not UNSET: + kwargs["allowed_domains"] = value + spider = crawler._create_spider(name="a", **kwargs) + mw = OffsiteMiddleware.from_crawler(crawler) + mw.spider_opened(spider) + request = Request("https://example.com") + assert mw.process_request(request, spider) is None + + +def test_process_request_invalid_domains(): + crawler = get_crawler(Spider) + allowed_domains = ["a.example", None, "http:////b.example", "//c.example"] + spider = crawler._create_spider(name="a", allowed_domains=allowed_domains) + mw = OffsiteMiddleware.from_crawler(crawler) + mw.spider_opened(spider) + request = Request("https://a.example") + assert mw.process_request(request, spider) is None + for letter in ("b", "c"): + request = Request(f"https://{letter}.example") + with pytest.raises(IgnoreRequest): + mw.process_request(request, spider) + + +@pytest.mark.parametrize( + ("allowed_domain", "url", "allowed"), + ( + ("example.com", "http://example.com/1", True), + ("example.com", "http://example.org/1", False), + ("example.com", "http://sub.example.com/1", True), + ("sub.example.com", "http://sub.example.com/1", True), + ("sub.example.com", "http://example.com/1", False), + ("example.com", "http://example.com:8000/1", True), + ("example.com", "http://example.org/example.com", False), + ("example.com", "http://example.org/foo.example.com", False), + ("example.com", "http://example.com.example", False), + ("a.example", "http://nota.example", False), + ("b.a.example", "http://notb.a.example", False), + ), +) +def test_request_scheduled_domain_filtering(allowed_domain, url, allowed): + crawler = get_crawler(Spider) + spider = crawler._create_spider(name="a", allowed_domains=[allowed_domain]) + mw = OffsiteMiddleware.from_crawler(crawler) + mw.spider_opened(spider) + request = Request(url) + if allowed: + assert mw.request_scheduled(request, spider) is None + else: + with pytest.raises(IgnoreRequest): + mw.request_scheduled(request, spider) + + +@pytest.mark.parametrize( + ("value", "filtered"), + ( + (UNSET, True), + (None, True), + (False, True), + (True, False), + ), +) +def test_request_scheduled_dont_filter(value, filtered): + crawler = get_crawler(Spider) + spider = crawler._create_spider(name="a", allowed_domains=["a.example"]) + mw = OffsiteMiddleware.from_crawler(crawler) + mw.spider_opened(spider) + kwargs = {} + if value is not UNSET: + kwargs["dont_filter"] = value + request = Request("https://b.example", **kwargs) + if filtered: + with pytest.raises(IgnoreRequest): + mw.request_scheduled(request, spider) + else: + assert mw.request_scheduled(request, spider) is None + + +@pytest.mark.parametrize( + "value", + ( + UNSET, + None, + [], + ), +) +def test_request_scheduled_no_allowed_domains(value): + crawler = get_crawler(Spider) + kwargs = {} + if value is not UNSET: + kwargs["allowed_domains"] = value + spider = crawler._create_spider(name="a", **kwargs) + mw = OffsiteMiddleware.from_crawler(crawler) + mw.spider_opened(spider) + request = Request("https://example.com") + assert mw.request_scheduled(request, spider) is None + + +def test_request_scheduled_invalid_domains(): + crawler = get_crawler(Spider) + allowed_domains = ["a.example", None, "http:////b.example", "//c.example"] + spider = crawler._create_spider(name="a", allowed_domains=allowed_domains) + mw = OffsiteMiddleware.from_crawler(crawler) + mw.spider_opened(spider) + request = Request("https://a.example") + assert mw.request_scheduled(request, spider) is None + for letter in ("b", "c"): + request = Request(f"https://{letter}.example") + with pytest.raises(IgnoreRequest): + mw.request_scheduled(request, spider) diff --git a/tests/test_downloadermiddleware_redirect.py b/tests/test_downloadermiddleware_redirect.py index dc15b672c..e37da9715 100644 --- a/tests/test_downloadermiddleware_redirect.py +++ b/tests/test_downloadermiddleware_redirect.py @@ -1,5 +1,9 @@ import unittest +from itertools import chain, product +import pytest + +from scrapy.downloadermiddlewares.httpproxy import HttpProxyMiddleware from scrapy.downloadermiddlewares.redirect import ( MetaRefreshMiddleware, RedirectMiddleware, @@ -7,22 +11,1030 @@ from scrapy.downloadermiddlewares.redirect import ( from scrapy.exceptions import IgnoreRequest from scrapy.http import HtmlResponse, Request, Response from scrapy.spiders import Spider +from scrapy.utils.misc import set_environ from scrapy.utils.test import get_crawler -class RedirectMiddlewareTest(unittest.TestCase): +class Base: + class Test(unittest.TestCase): + def test_priority_adjust(self): + req = Request("http://a.com") + rsp = self.get_response(req, "http://a.com/redirected") + req2 = self.mw.process_response(req, rsp, self.spider) + self.assertGreater(req2.priority, req.priority) + + def test_dont_redirect(self): + url = "http://www.example.com/301" + url2 = "http://www.example.com/redirected" + req = Request(url, meta={"dont_redirect": True}) + rsp = self.get_response(req, url2) + + r = self.mw.process_response(req, rsp, self.spider) + assert isinstance(r, Response) + assert r is rsp + + # Test that it redirects when dont_redirect is False + req = Request(url, meta={"dont_redirect": False}) + rsp = self.get_response(req, url2) + + r = self.mw.process_response(req, rsp, self.spider) + assert isinstance(r, Request) + + def test_post(self): + url = "http://www.example.com/302" + url2 = "http://www.example.com/redirected2" + req = Request( + url, + method="POST", + body="test", + headers={"Content-Type": "text/plain", "Content-length": "4"}, + ) + rsp = self.get_response(req, url2) + + req2 = self.mw.process_response(req, rsp, self.spider) + assert isinstance(req2, Request) + self.assertEqual(req2.url, url2) + self.assertEqual(req2.method, "GET") + assert ( + "Content-Type" not in req2.headers + ), "Content-Type header must not be present in redirected request" + assert ( + "Content-Length" not in req2.headers + ), "Content-Length header must not be present in redirected request" + assert not req2.body, f"Redirected body must be empty, not '{req2.body}'" + + def test_max_redirect_times(self): + self.mw.max_redirect_times = 1 + req = Request("http://scrapytest.org/302") + rsp = self.get_response(req, "/redirected") + + req = self.mw.process_response(req, rsp, self.spider) + assert isinstance(req, Request) + assert "redirect_times" in req.meta + self.assertEqual(req.meta["redirect_times"], 1) + self.assertRaises( + IgnoreRequest, self.mw.process_response, req, rsp, self.spider + ) + + 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) + assert isinstance(req, Request) + self.assertRaises( + IgnoreRequest, self.mw.process_response, req, rsp, self.spider + ) + + 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) + rsp2 = self.get_response(req1, "/redirected2") + req3 = self.mw.process_response(req2, rsp2, self.spider) + + self.assertEqual(req2.url, "http://scrapytest.org/redirected") + self.assertEqual( + req2.meta["redirect_urls"], ["http://scrapytest.org/first"] + ) + self.assertEqual(req3.url, "http://scrapytest.org/redirected2") + self.assertEqual( + req3.meta["redirect_urls"], + ["http://scrapytest.org/first", "http://scrapytest.org/redirected"], + ) + + 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) + rsp2 = self.get_response(req2, "/redirected2") + req3 = self.mw.process_response(req2, rsp2, self.spider) + self.assertEqual(req2.meta["redirect_reasons"], [self.reason]) + self.assertEqual(req3.meta["redirect_reasons"], [self.reason, self.reason]) + + def test_cross_origin_header_dropping(self): + safe_headers = {"A": "B"} + cookie_header = {"Cookie": "a=b"} + authorization_header = {"Authorization": "Bearer 123456"} + + original_request = Request( + "https://example.com", + headers={**safe_headers, **cookie_header, **authorization_header}, + ) + + # Redirects to the same origin (same scheme, same domain, same port) + # keep all headers. + internal_response = self.get_response( + original_request, "https://example.com/a" + ) + internal_redirect_request = self.mw.process_response( + original_request, internal_response, self.spider + ) + self.assertIsInstance(internal_redirect_request, Request) + self.assertEqual( + original_request.headers, internal_redirect_request.headers + ) + + # Redirects to the same origin (same scheme, same domain, same port) + # keep all headers also when the scheme is http. + http_request = Request( + "http://example.com", + headers={**safe_headers, **cookie_header, **authorization_header}, + ) + http_response = self.get_response(http_request, "http://example.com/a") + http_redirect_request = self.mw.process_response( + http_request, http_response, self.spider + ) + self.assertIsInstance(http_redirect_request, Request) + self.assertEqual(http_request.headers, http_redirect_request.headers) + + # For default ports, whether the port is explicit or implicit does not + # affect the outcome, it is still the same origin. + to_explicit_port_response = self.get_response( + original_request, "https://example.com:443/a" + ) + to_explicit_port_redirect_request = self.mw.process_response( + original_request, to_explicit_port_response, self.spider + ) + self.assertIsInstance(to_explicit_port_redirect_request, Request) + self.assertEqual( + original_request.headers, to_explicit_port_redirect_request.headers + ) + + # For default ports, whether the port is explicit or implicit does not + # affect the outcome, it is still the same origin. + to_implicit_port_response = self.get_response( + original_request, "https://example.com/a" + ) + to_implicit_port_redirect_request = self.mw.process_response( + original_request, to_implicit_port_response, self.spider + ) + self.assertIsInstance(to_implicit_port_redirect_request, Request) + self.assertEqual( + original_request.headers, to_implicit_port_redirect_request.headers + ) + + # A port change drops the Authorization header because the origin + # changes, but keeps the Cookie header because the domain remains the + # same. + different_port_response = self.get_response( + original_request, "https://example.com:8080/a" + ) + different_port_redirect_request = self.mw.process_response( + original_request, different_port_response, self.spider + ) + self.assertIsInstance(different_port_redirect_request, Request) + self.assertEqual( + {**safe_headers, **cookie_header}, + different_port_redirect_request.headers.to_unicode_dict(), + ) + + # A domain change drops both the Authorization and the Cookie header. + external_response = self.get_response( + original_request, "https://example.org/a" + ) + external_redirect_request = self.mw.process_response( + original_request, external_response, self.spider + ) + self.assertIsInstance(external_redirect_request, Request) + self.assertEqual( + safe_headers, external_redirect_request.headers.to_unicode_dict() + ) + + # A scheme upgrade (http → https) drops the Authorization header + # because the origin changes, but keeps the Cookie header because the + # 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 + ) + self.assertIsInstance(upgrade_redirect_request, Request) + self.assertEqual( + {**safe_headers, **cookie_header}, + upgrade_redirect_request.headers.to_unicode_dict(), + ) + + # A scheme downgrade (https → http) drops the Authorization header + # because the origin changes, and the Cookie header because its value + # cannot indicate whether the cookies were secure (HTTPS-only) or not. + # + # Note: If the Cookie header is set by the cookie management + # middleware, as recommended in the docs, the dropping of Cookie on + # scheme downgrade is not an issue, because the cookie management + # middleware will add again the Cookie header to the new request if + # appropriate. + downgrade_response = self.get_response( + original_request, "http://example.com/a" + ) + downgrade_redirect_request = self.mw.process_response( + original_request, downgrade_response, self.spider + ) + self.assertIsInstance(downgrade_redirect_request, Request) + self.assertEqual( + safe_headers, + downgrade_redirect_request.headers.to_unicode_dict(), + ) + + def test_meta_proxy_http_absolute(self): + crawler = get_crawler() + redirect_mw = self.mwcls.from_crawler(crawler) + proxy_mw = HttpProxyMiddleware.from_crawler(crawler) + + meta = {"proxy": "https://a:@a.example"} + request1 = Request("http://example.com", meta=meta) + spider = None + proxy_mw.process_request(request1, spider) + + self.assertEqual(request1.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request1.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request1.meta["proxy"], "https://a.example") + + response1 = self.get_response(request1, "http://example.com") + request2 = redirect_mw.process_response(request1, response1, spider) + + self.assertIsInstance(request2, Request) + self.assertEqual(request2.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request2.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request2.meta["proxy"], "https://a.example") + + proxy_mw.process_request(request2, spider) + + self.assertEqual(request2.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request2.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request2.meta["proxy"], "https://a.example") + + response2 = self.get_response(request2, "http://example.com") + request3 = redirect_mw.process_response(request2, response2, spider) + + self.assertIsInstance(request3, Request) + self.assertEqual(request3.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request3.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request3.meta["proxy"], "https://a.example") + + proxy_mw.process_request(request3, spider) + + self.assertEqual(request3.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request3.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request3.meta["proxy"], "https://a.example") + + def test_meta_proxy_http_relative(self): + crawler = get_crawler() + redirect_mw = self.mwcls.from_crawler(crawler) + proxy_mw = HttpProxyMiddleware.from_crawler(crawler) + + meta = {"proxy": "https://a:@a.example"} + request1 = Request("http://example.com", meta=meta) + spider = None + proxy_mw.process_request(request1, spider) + + self.assertEqual(request1.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request1.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request1.meta["proxy"], "https://a.example") + + response1 = self.get_response(request1, "/a") + request2 = redirect_mw.process_response(request1, response1, spider) + + self.assertIsInstance(request2, Request) + self.assertEqual(request2.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request2.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request2.meta["proxy"], "https://a.example") + + proxy_mw.process_request(request2, spider) + + self.assertEqual(request2.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request2.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request2.meta["proxy"], "https://a.example") + + response2 = self.get_response(request2, "/a") + request3 = redirect_mw.process_response(request2, response2, spider) + + self.assertIsInstance(request3, Request) + self.assertEqual(request3.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request3.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request3.meta["proxy"], "https://a.example") + + proxy_mw.process_request(request3, spider) + + self.assertEqual(request3.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request3.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request3.meta["proxy"], "https://a.example") + + def test_meta_proxy_https_absolute(self): + crawler = get_crawler() + redirect_mw = self.mwcls.from_crawler(crawler) + proxy_mw = HttpProxyMiddleware.from_crawler(crawler) + + meta = {"proxy": "https://a:@a.example"} + request1 = Request("https://example.com", meta=meta) + spider = None + proxy_mw.process_request(request1, spider) + + self.assertEqual(request1.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request1.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request1.meta["proxy"], "https://a.example") + + response1 = self.get_response(request1, "https://example.com") + request2 = redirect_mw.process_response(request1, response1, spider) + + self.assertIsInstance(request2, Request) + self.assertEqual(request2.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request2.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request2.meta["proxy"], "https://a.example") + + proxy_mw.process_request(request2, spider) + + self.assertEqual(request2.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request2.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request2.meta["proxy"], "https://a.example") + + response2 = self.get_response(request2, "https://example.com") + request3 = redirect_mw.process_response(request2, response2, spider) + + self.assertIsInstance(request3, Request) + self.assertEqual(request3.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request3.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request3.meta["proxy"], "https://a.example") + + proxy_mw.process_request(request3, spider) + + self.assertEqual(request3.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request3.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request3.meta["proxy"], "https://a.example") + + def test_meta_proxy_https_relative(self): + crawler = get_crawler() + redirect_mw = self.mwcls.from_crawler(crawler) + proxy_mw = HttpProxyMiddleware.from_crawler(crawler) + + meta = {"proxy": "https://a:@a.example"} + request1 = Request("https://example.com", meta=meta) + spider = None + proxy_mw.process_request(request1, spider) + + self.assertEqual(request1.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request1.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request1.meta["proxy"], "https://a.example") + + response1 = self.get_response(request1, "/a") + request2 = redirect_mw.process_response(request1, response1, spider) + + self.assertIsInstance(request2, Request) + self.assertEqual(request2.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request2.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request2.meta["proxy"], "https://a.example") + + proxy_mw.process_request(request2, spider) + + self.assertEqual(request2.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request2.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request2.meta["proxy"], "https://a.example") + + response2 = self.get_response(request2, "/a") + request3 = redirect_mw.process_response(request2, response2, spider) + + self.assertIsInstance(request3, Request) + self.assertEqual(request3.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request3.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request3.meta["proxy"], "https://a.example") + + proxy_mw.process_request(request3, spider) + + self.assertEqual(request3.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request3.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request3.meta["proxy"], "https://a.example") + + def test_meta_proxy_http_to_https(self): + crawler = get_crawler() + redirect_mw = self.mwcls.from_crawler(crawler) + proxy_mw = HttpProxyMiddleware.from_crawler(crawler) + + meta = {"proxy": "https://a:@a.example"} + request1 = Request("http://example.com", meta=meta) + spider = None + proxy_mw.process_request(request1, spider) + + self.assertEqual(request1.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request1.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request1.meta["proxy"], "https://a.example") + + response1 = self.get_response(request1, "https://example.com") + request2 = redirect_mw.process_response(request1, response1, spider) + + self.assertIsInstance(request2, Request) + self.assertEqual(request2.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request2.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request2.meta["proxy"], "https://a.example") + + proxy_mw.process_request(request2, spider) + + self.assertEqual(request2.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request2.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request2.meta["proxy"], "https://a.example") + + response2 = self.get_response(request2, "http://example.com") + request3 = redirect_mw.process_response(request2, response2, spider) + + self.assertIsInstance(request3, Request) + self.assertEqual(request3.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request3.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request3.meta["proxy"], "https://a.example") + + proxy_mw.process_request(request3, spider) + + self.assertEqual(request3.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request3.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request3.meta["proxy"], "https://a.example") + + def test_meta_proxy_https_to_http(self): + crawler = get_crawler() + redirect_mw = self.mwcls.from_crawler(crawler) + proxy_mw = HttpProxyMiddleware.from_crawler(crawler) + + meta = {"proxy": "https://a:@a.example"} + request1 = Request("https://example.com", meta=meta) + spider = None + proxy_mw.process_request(request1, spider) + + self.assertEqual(request1.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request1.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request1.meta["proxy"], "https://a.example") + + response1 = self.get_response(request1, "http://example.com") + request2 = redirect_mw.process_response(request1, response1, spider) + + self.assertIsInstance(request2, Request) + self.assertEqual(request2.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request2.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request2.meta["proxy"], "https://a.example") + + proxy_mw.process_request(request2, spider) + + self.assertEqual(request2.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request2.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request2.meta["proxy"], "https://a.example") + + response2 = self.get_response(request2, "https://example.com") + request3 = redirect_mw.process_response(request2, response2, spider) + + self.assertIsInstance(request3, Request) + self.assertEqual(request3.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request3.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request3.meta["proxy"], "https://a.example") + + proxy_mw.process_request(request3, spider) + + self.assertEqual(request3.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request3.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request3.meta["proxy"], "https://a.example") + + def test_system_proxy_http_absolute(self): + crawler = get_crawler() + redirect_mw = self.mwcls.from_crawler(crawler) + env = { + "http_proxy": "https://a:@a.example", + } + with set_environ(**env): + proxy_mw = HttpProxyMiddleware.from_crawler(crawler) + + request1 = Request("http://example.com") + spider = None + proxy_mw.process_request(request1, spider) + + self.assertEqual(request1.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request1.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request1.meta["proxy"], "https://a.example") + + response1 = self.get_response(request1, "http://example.com") + request2 = redirect_mw.process_response(request1, response1, spider) + + self.assertIsInstance(request2, Request) + self.assertEqual(request2.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request2.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request2.meta["proxy"], "https://a.example") + + proxy_mw.process_request(request2, spider) + + self.assertEqual(request2.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request2.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request2.meta["proxy"], "https://a.example") + + response2 = self.get_response(request2, "http://example.com") + request3 = redirect_mw.process_response(request2, response2, spider) + + self.assertIsInstance(request3, Request) + self.assertEqual(request3.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request3.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request3.meta["proxy"], "https://a.example") + + proxy_mw.process_request(request3, spider) + + self.assertEqual(request3.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request3.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request3.meta["proxy"], "https://a.example") + + def test_system_proxy_http_relative(self): + crawler = get_crawler() + redirect_mw = self.mwcls.from_crawler(crawler) + env = { + "http_proxy": "https://a:@a.example", + } + with set_environ(**env): + proxy_mw = HttpProxyMiddleware.from_crawler(crawler) + + request1 = Request("http://example.com") + spider = None + proxy_mw.process_request(request1, spider) + + self.assertEqual(request1.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request1.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request1.meta["proxy"], "https://a.example") + + response1 = self.get_response(request1, "/a") + request2 = redirect_mw.process_response(request1, response1, spider) + + self.assertIsInstance(request2, Request) + self.assertEqual(request2.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request2.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request2.meta["proxy"], "https://a.example") + + proxy_mw.process_request(request2, spider) + + self.assertEqual(request2.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request2.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request2.meta["proxy"], "https://a.example") + + response2 = self.get_response(request2, "/a") + request3 = redirect_mw.process_response(request2, response2, spider) + + self.assertIsInstance(request3, Request) + self.assertEqual(request3.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request3.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request3.meta["proxy"], "https://a.example") + + proxy_mw.process_request(request3, spider) + + self.assertEqual(request3.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request3.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request3.meta["proxy"], "https://a.example") + + def test_system_proxy_https_absolute(self): + crawler = get_crawler() + redirect_mw = self.mwcls.from_crawler(crawler) + env = { + "https_proxy": "https://a:@a.example", + } + with set_environ(**env): + proxy_mw = HttpProxyMiddleware.from_crawler(crawler) + + request1 = Request("https://example.com") + spider = None + proxy_mw.process_request(request1, spider) + + self.assertEqual(request1.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request1.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request1.meta["proxy"], "https://a.example") + + response1 = self.get_response(request1, "https://example.com") + request2 = redirect_mw.process_response(request1, response1, spider) + + self.assertIsInstance(request2, Request) + self.assertEqual(request2.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request2.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request2.meta["proxy"], "https://a.example") + + proxy_mw.process_request(request2, spider) + + self.assertEqual(request2.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request2.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request2.meta["proxy"], "https://a.example") + + response2 = self.get_response(request2, "https://example.com") + request3 = redirect_mw.process_response(request2, response2, spider) + + self.assertIsInstance(request3, Request) + self.assertEqual(request3.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request3.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request3.meta["proxy"], "https://a.example") + + proxy_mw.process_request(request3, spider) + + self.assertEqual(request3.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request3.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request3.meta["proxy"], "https://a.example") + + def test_system_proxy_https_relative(self): + crawler = get_crawler() + redirect_mw = self.mwcls.from_crawler(crawler) + env = { + "https_proxy": "https://a:@a.example", + } + with set_environ(**env): + proxy_mw = HttpProxyMiddleware.from_crawler(crawler) + + request1 = Request("https://example.com") + spider = None + proxy_mw.process_request(request1, spider) + + self.assertEqual(request1.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request1.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request1.meta["proxy"], "https://a.example") + + response1 = self.get_response(request1, "/a") + request2 = redirect_mw.process_response(request1, response1, spider) + + self.assertIsInstance(request2, Request) + self.assertEqual(request2.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request2.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request2.meta["proxy"], "https://a.example") + + proxy_mw.process_request(request2, spider) + + self.assertEqual(request2.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request2.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request2.meta["proxy"], "https://a.example") + + response2 = self.get_response(request2, "/a") + request3 = redirect_mw.process_response(request2, response2, spider) + + self.assertIsInstance(request3, Request) + self.assertEqual(request3.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request3.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request3.meta["proxy"], "https://a.example") + + proxy_mw.process_request(request3, spider) + + self.assertEqual(request3.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request3.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request3.meta["proxy"], "https://a.example") + + def test_system_proxy_proxied_http_to_proxied_https(self): + crawler = get_crawler() + redirect_mw = self.mwcls.from_crawler(crawler) + env = { + "http_proxy": "https://a:@a.example", + "https_proxy": "https://b:@b.example", + } + with set_environ(**env): + proxy_mw = HttpProxyMiddleware.from_crawler(crawler) + + request1 = Request("http://example.com") + spider = None + proxy_mw.process_request(request1, spider) + + self.assertEqual(request1.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request1.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request1.meta["proxy"], "https://a.example") + + response1 = self.get_response(request1, "https://example.com") + request2 = redirect_mw.process_response(request1, response1, spider) + + self.assertIsInstance(request2, Request) + self.assertNotIn("Proxy-Authorization", request2.headers) + self.assertNotIn("_auth_proxy", request2.meta) + self.assertNotIn("proxy", request2.meta) + + proxy_mw.process_request(request2, spider) + + self.assertEqual(request2.headers["Proxy-Authorization"], b"Basic Yjo=") + self.assertEqual(request2.meta["_auth_proxy"], "https://b.example") + self.assertEqual(request2.meta["proxy"], "https://b.example") + + response2 = self.get_response(request2, "http://example.com") + request3 = redirect_mw.process_response(request2, response2, spider) + + self.assertIsInstance(request3, Request) + self.assertNotIn("Proxy-Authorization", request3.headers) + self.assertNotIn("_auth_proxy", request3.meta) + self.assertNotIn("proxy", request3.meta) + + proxy_mw.process_request(request3, spider) + + self.assertEqual(request3.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request3.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request3.meta["proxy"], "https://a.example") + + def test_system_proxy_proxied_http_to_unproxied_https(self): + crawler = get_crawler() + redirect_mw = self.mwcls.from_crawler(crawler) + env = { + "http_proxy": "https://a:@a.example", + } + with set_environ(**env): + proxy_mw = HttpProxyMiddleware.from_crawler(crawler) + + request1 = Request("http://example.com") + spider = None + proxy_mw.process_request(request1, spider) + + self.assertEqual(request1.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request1.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request1.meta["proxy"], "https://a.example") + + response1 = self.get_response(request1, "https://example.com") + request2 = redirect_mw.process_response(request1, response1, spider) + + self.assertIsInstance(request2, Request) + self.assertNotIn("Proxy-Authorization", request2.headers) + self.assertNotIn("_auth_proxy", request2.meta) + self.assertNotIn("proxy", request2.meta) + + proxy_mw.process_request(request2, spider) + + self.assertNotIn("Proxy-Authorization", request2.headers) + self.assertNotIn("_auth_proxy", request2.meta) + self.assertNotIn("proxy", request2.meta) + + response2 = self.get_response(request2, "http://example.com") + request3 = redirect_mw.process_response(request2, response2, spider) + + self.assertIsInstance(request3, Request) + self.assertNotIn("Proxy-Authorization", request3.headers) + self.assertNotIn("_auth_proxy", request3.meta) + self.assertNotIn("proxy", request3.meta) + + proxy_mw.process_request(request3, spider) + + self.assertEqual(request3.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request3.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request3.meta["proxy"], "https://a.example") + + def test_system_proxy_unproxied_http_to_proxied_https(self): + crawler = get_crawler() + redirect_mw = self.mwcls.from_crawler(crawler) + env = { + "https_proxy": "https://b:@b.example", + } + with set_environ(**env): + proxy_mw = HttpProxyMiddleware.from_crawler(crawler) + + request1 = Request("http://example.com") + spider = None + proxy_mw.process_request(request1, spider) + + self.assertNotIn("Proxy-Authorization", request1.headers) + self.assertNotIn("_auth_proxy", request1.meta) + self.assertNotIn("proxy", request1.meta) + + response1 = self.get_response(request1, "https://example.com") + request2 = redirect_mw.process_response(request1, response1, spider) + + self.assertIsInstance(request2, Request) + self.assertNotIn("Proxy-Authorization", request2.headers) + self.assertNotIn("_auth_proxy", request2.meta) + self.assertNotIn("proxy", request2.meta) + + proxy_mw.process_request(request2, spider) + + self.assertEqual(request2.headers["Proxy-Authorization"], b"Basic Yjo=") + self.assertEqual(request2.meta["_auth_proxy"], "https://b.example") + self.assertEqual(request2.meta["proxy"], "https://b.example") + + response2 = self.get_response(request2, "http://example.com") + request3 = redirect_mw.process_response(request2, response2, spider) + + self.assertIsInstance(request3, Request) + self.assertNotIn("Proxy-Authorization", request3.headers) + self.assertNotIn("_auth_proxy", request3.meta) + self.assertNotIn("proxy", request3.meta) + + proxy_mw.process_request(request3, spider) + + self.assertNotIn("Proxy-Authorization", request3.headers) + self.assertNotIn("_auth_proxy", request3.meta) + self.assertNotIn("proxy", request3.meta) + + def test_system_proxy_unproxied_http_to_unproxied_https(self): + crawler = get_crawler() + redirect_mw = self.mwcls.from_crawler(crawler) + proxy_mw = HttpProxyMiddleware.from_crawler(crawler) + + request1 = Request("http://example.com") + spider = None + proxy_mw.process_request(request1, spider) + + self.assertNotIn("Proxy-Authorization", request1.headers) + self.assertNotIn("_auth_proxy", request1.meta) + self.assertNotIn("proxy", request1.meta) + + response1 = self.get_response(request1, "https://example.com") + request2 = redirect_mw.process_response(request1, response1, spider) + + self.assertIsInstance(request2, Request) + self.assertNotIn("Proxy-Authorization", request2.headers) + self.assertNotIn("_auth_proxy", request2.meta) + self.assertNotIn("proxy", request2.meta) + + proxy_mw.process_request(request2, spider) + + self.assertNotIn("Proxy-Authorization", request2.headers) + self.assertNotIn("_auth_proxy", request2.meta) + self.assertNotIn("proxy", request2.meta) + + response2 = self.get_response(request2, "http://example.com") + request3 = redirect_mw.process_response(request2, response2, spider) + + self.assertIsInstance(request3, Request) + self.assertNotIn("Proxy-Authorization", request3.headers) + self.assertNotIn("_auth_proxy", request3.meta) + self.assertNotIn("proxy", request3.meta) + + proxy_mw.process_request(request3, spider) + + self.assertNotIn("Proxy-Authorization", request3.headers) + self.assertNotIn("_auth_proxy", request3.meta) + self.assertNotIn("proxy", request3.meta) + + def test_system_proxy_proxied_https_to_proxied_http(self): + crawler = get_crawler() + redirect_mw = self.mwcls.from_crawler(crawler) + env = { + "http_proxy": "https://a:@a.example", + "https_proxy": "https://b:@b.example", + } + with set_environ(**env): + proxy_mw = HttpProxyMiddleware.from_crawler(crawler) + + request1 = Request("https://example.com") + spider = None + proxy_mw.process_request(request1, spider) + + self.assertEqual(request1.headers["Proxy-Authorization"], b"Basic Yjo=") + self.assertEqual(request1.meta["_auth_proxy"], "https://b.example") + self.assertEqual(request1.meta["proxy"], "https://b.example") + + response1 = self.get_response(request1, "http://example.com") + request2 = redirect_mw.process_response(request1, response1, spider) + + self.assertIsInstance(request2, Request) + self.assertNotIn("Proxy-Authorization", request2.headers) + self.assertNotIn("_auth_proxy", request2.meta) + self.assertNotIn("proxy", request2.meta) + + proxy_mw.process_request(request2, spider) + + self.assertEqual(request2.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request2.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request2.meta["proxy"], "https://a.example") + + response2 = self.get_response(request2, "https://example.com") + request3 = redirect_mw.process_response(request2, response2, spider) + + self.assertIsInstance(request3, Request) + self.assertNotIn("Proxy-Authorization", request3.headers) + self.assertNotIn("_auth_proxy", request3.meta) + self.assertNotIn("proxy", request3.meta) + + proxy_mw.process_request(request3, spider) + + self.assertEqual(request3.headers["Proxy-Authorization"], b"Basic Yjo=") + self.assertEqual(request3.meta["_auth_proxy"], "https://b.example") + self.assertEqual(request3.meta["proxy"], "https://b.example") + + def test_system_proxy_proxied_https_to_unproxied_http(self): + crawler = get_crawler() + redirect_mw = self.mwcls.from_crawler(crawler) + env = { + "https_proxy": "https://b:@b.example", + } + with set_environ(**env): + proxy_mw = HttpProxyMiddleware.from_crawler(crawler) + + request1 = Request("https://example.com") + spider = None + proxy_mw.process_request(request1, spider) + + self.assertEqual(request1.headers["Proxy-Authorization"], b"Basic Yjo=") + self.assertEqual(request1.meta["_auth_proxy"], "https://b.example") + self.assertEqual(request1.meta["proxy"], "https://b.example") + + response1 = self.get_response(request1, "http://example.com") + request2 = redirect_mw.process_response(request1, response1, spider) + + self.assertIsInstance(request2, Request) + self.assertNotIn("Proxy-Authorization", request2.headers) + self.assertNotIn("_auth_proxy", request2.meta) + self.assertNotIn("proxy", request2.meta) + + proxy_mw.process_request(request2, spider) + + self.assertNotIn("Proxy-Authorization", request2.headers) + self.assertNotIn("_auth_proxy", request2.meta) + self.assertNotIn("proxy", request2.meta) + + response2 = self.get_response(request2, "https://example.com") + request3 = redirect_mw.process_response(request2, response2, spider) + + self.assertIsInstance(request3, Request) + self.assertNotIn("Proxy-Authorization", request3.headers) + self.assertNotIn("_auth_proxy", request3.meta) + self.assertNotIn("proxy", request3.meta) + + proxy_mw.process_request(request3, spider) + + self.assertEqual(request3.headers["Proxy-Authorization"], b"Basic Yjo=") + self.assertEqual(request3.meta["_auth_proxy"], "https://b.example") + self.assertEqual(request3.meta["proxy"], "https://b.example") + + def test_system_proxy_unproxied_https_to_proxied_http(self): + crawler = get_crawler() + redirect_mw = self.mwcls.from_crawler(crawler) + env = { + "http_proxy": "https://a:@a.example", + } + with set_environ(**env): + proxy_mw = HttpProxyMiddleware.from_crawler(crawler) + + request1 = Request("https://example.com") + spider = None + proxy_mw.process_request(request1, spider) + + self.assertNotIn("Proxy-Authorization", request1.headers) + self.assertNotIn("_auth_proxy", request1.meta) + self.assertNotIn("proxy", request1.meta) + + response1 = self.get_response(request1, "http://example.com") + request2 = redirect_mw.process_response(request1, response1, spider) + + self.assertIsInstance(request2, Request) + self.assertNotIn("Proxy-Authorization", request2.headers) + self.assertNotIn("_auth_proxy", request2.meta) + self.assertNotIn("proxy", request2.meta) + + proxy_mw.process_request(request2, spider) + + self.assertEqual(request2.headers["Proxy-Authorization"], b"Basic YTo=") + self.assertEqual(request2.meta["_auth_proxy"], "https://a.example") + self.assertEqual(request2.meta["proxy"], "https://a.example") + + response2 = self.get_response(request2, "https://example.com") + request3 = redirect_mw.process_response(request2, response2, spider) + + self.assertIsInstance(request3, Request) + self.assertNotIn("Proxy-Authorization", request3.headers) + self.assertNotIn("_auth_proxy", request3.meta) + self.assertNotIn("proxy", request3.meta) + + proxy_mw.process_request(request3, spider) + + self.assertNotIn("Proxy-Authorization", request3.headers) + self.assertNotIn("_auth_proxy", request3.meta) + self.assertNotIn("proxy", request3.meta) + + def test_system_proxy_unproxied_https_to_unproxied_http(self): + crawler = get_crawler() + redirect_mw = self.mwcls.from_crawler(crawler) + proxy_mw = HttpProxyMiddleware.from_crawler(crawler) + + request1 = Request("https://example.com") + spider = None + proxy_mw.process_request(request1, spider) + + self.assertNotIn("Proxy-Authorization", request1.headers) + self.assertNotIn("_auth_proxy", request1.meta) + self.assertNotIn("proxy", request1.meta) + + response1 = self.get_response(request1, "http://example.com") + request2 = redirect_mw.process_response(request1, response1, spider) + + self.assertIsInstance(request2, Request) + self.assertNotIn("Proxy-Authorization", request2.headers) + self.assertNotIn("_auth_proxy", request2.meta) + self.assertNotIn("proxy", request2.meta) + + proxy_mw.process_request(request2, spider) + + self.assertNotIn("Proxy-Authorization", request2.headers) + self.assertNotIn("_auth_proxy", request2.meta) + self.assertNotIn("proxy", request2.meta) + + response2 = self.get_response(request2, "https://example.com") + request3 = redirect_mw.process_response(request2, response2, spider) + + self.assertIsInstance(request3, Request) + self.assertNotIn("Proxy-Authorization", request3.headers) + self.assertNotIn("_auth_proxy", request3.meta) + self.assertNotIn("proxy", request3.meta) + + proxy_mw.process_request(request3, spider) + + self.assertNotIn("Proxy-Authorization", request3.headers) + self.assertNotIn("_auth_proxy", request3.meta) + self.assertNotIn("proxy", request3.meta) + + +class RedirectMiddlewareTest(Base.Test): + mwcls = RedirectMiddleware + reason = 302 + def setUp(self): self.crawler = get_crawler(Spider) self.spider = self.crawler._create_spider("foo") - self.mw = RedirectMiddleware.from_crawler(self.crawler) + self.mw = self.mwcls.from_crawler(self.crawler) - def test_priority_adjust(self): - req = Request("http://a.com") - rsp = Response( - "http://a.com", headers={"Location": "http://a.com/redirected"}, status=301 - ) - req2 = self.mw.process_response(req, rsp, self.spider) - assert req2.priority > req.priority + def get_response(self, request, location, status=302): + headers = {"Location": location} + return Response(request.url, status=status, headers=headers) def test_redirect_3xx_permanent(self): def _test(method, status=301): @@ -52,51 +1064,6 @@ class RedirectMiddlewareTest(unittest.TestCase): _test("POST", status=308) _test("HEAD", status=308) - def test_dont_redirect(self): - url = "http://www.example.com/301" - url2 = "http://www.example.com/redirected" - req = Request(url, meta={"dont_redirect": True}) - rsp = Response(url, headers={"Location": url2}, status=301) - - r = self.mw.process_response(req, rsp, self.spider) - assert isinstance(r, Response) - assert r is rsp - - # Test that it redirects when dont_redirect is False - req = Request(url, meta={"dont_redirect": False}) - rsp = Response(url2, status=200) - - r = self.mw.process_response(req, rsp, self.spider) - assert isinstance(r, Response) - assert r is rsp - - def test_redirect_302(self): - url = "http://www.example.com/302" - url2 = "http://www.example.com/redirected2" - req = Request( - url, - method="POST", - body="test", - headers={"Content-Type": "text/plain", "Content-length": "4"}, - ) - rsp = Response(url, headers={"Location": url2}, status=302) - - req2 = self.mw.process_response(req, rsp, self.spider) - assert isinstance(req2, Request) - self.assertEqual(req2.url, url2) - self.assertEqual(req2.method, "GET") - assert ( - "Content-Type" not in req2.headers - ), "Content-Type header must not be present in redirected request" - assert ( - "Content-Length" not in req2.headers - ), "Content-Length header must not be present in redirected request" - assert not req2.body, f"Redirected body must be empty, not '{req2.body}'" - - # 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 - def test_redirect_302_head(self): url = "http://www.example.com/302" url2 = "http://www.example.com/redirected2" @@ -108,10 +1075,6 @@ class RedirectMiddlewareTest(unittest.TestCase): self.assertEqual(req2.url, url2) self.assertEqual(req2.method, "HEAD") - # 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 - def test_redirect_302_relative(self): url = "http://www.example.com/302" url2 = "///i8n.example2.com/302" @@ -124,81 +1087,6 @@ class RedirectMiddlewareTest(unittest.TestCase): self.assertEqual(req2.url, url3) self.assertEqual(req2.method, "HEAD") - # 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 - - def test_max_redirect_times(self): - self.mw.max_redirect_times = 1 - req = Request("http://scrapytest.org/302") - rsp = Response( - "http://scrapytest.org/302", headers={"Location": "/redirected"}, status=302 - ) - - req = self.mw.process_response(req, rsp, self.spider) - assert isinstance(req, Request) - assert "redirect_times" in req.meta - self.assertEqual(req.meta["redirect_times"], 1) - self.assertRaises( - IgnoreRequest, self.mw.process_response, req, rsp, self.spider - ) - - def test_ttl(self): - self.mw.max_redirect_times = 100 - req = Request("http://scrapytest.org/302", meta={"redirect_ttl": 1}) - rsp = Response( - "http://www.scrapytest.org/302", - headers={"Location": "/redirected"}, - status=302, - ) - - req = self.mw.process_response(req, rsp, self.spider) - assert isinstance(req, Request) - self.assertRaises( - IgnoreRequest, self.mw.process_response, req, rsp, self.spider - ) - - def test_redirect_urls(self): - req1 = Request("http://scrapytest.org/first") - rsp1 = Response( - "http://scrapytest.org/first", - headers={"Location": "/redirected"}, - status=302, - ) - req2 = self.mw.process_response(req1, rsp1, self.spider) - rsp2 = Response( - "http://scrapytest.org/redirected", - headers={"Location": "/redirected2"}, - status=302, - ) - req3 = self.mw.process_response(req2, rsp2, self.spider) - - self.assertEqual(req2.url, "http://scrapytest.org/redirected") - self.assertEqual(req2.meta["redirect_urls"], ["http://scrapytest.org/first"]) - self.assertEqual(req3.url, "http://scrapytest.org/redirected2") - self.assertEqual( - req3.meta["redirect_urls"], - ["http://scrapytest.org/first", "http://scrapytest.org/redirected"], - ) - - def test_redirect_reasons(self): - req1 = Request("http://scrapytest.org/first") - rsp1 = Response( - "http://scrapytest.org/first", - headers={"Location": "/redirected1"}, - status=301, - ) - req2 = self.mw.process_response(req1, rsp1, self.spider) - rsp2 = Response( - "http://scrapytest.org/redirected1", - headers={"Location": "/redirected2"}, - status=301, - ) - req3 = self.mw.process_response(req2, rsp2, self.spider) - - self.assertEqual(req2.meta["redirect_reasons"], [301]) - self.assertEqual(req3.meta["redirect_reasons"], [301, 301]) - def test_spider_handling(self): smartspider = self.crawler._create_spider("smarty") smartspider.handle_httpstatus_list = [404, 301, 302] @@ -237,7 +1125,7 @@ class RedirectMiddlewareTest(unittest.TestCase): def test_utf8_location(self): req = Request("http://scrapytest.org/first") - utf8_location = "/ação".encode("utf-8") # header using UTF-8 encoding + utf8_location = "/ação".encode() # header using UTF-8 encoding resp = Response( "http://scrapytest.org/first", headers={"Location": utf8_location}, @@ -247,22 +1135,84 @@ class RedirectMiddlewareTest(unittest.TestCase): perc_encoded_utf8_url = "http://scrapytest.org/a%C3%A7%C3%A3o" self.assertEqual(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 + + +SCHEME_PARAMS = ("url", "location", "target") +HTTP_SCHEMES = ("http", "https") +NON_HTTP_SCHEMES = ("data", "file", "ftp", "s3", "foo") +REDIRECT_SCHEME_CASES = ( + # http/https → http/https redirects + *( + ( + f"{input_scheme}://example.com/a", + f"{output_scheme}://example.com/b", + f"{output_scheme}://example.com/b", + ) + for input_scheme, output_scheme in product(HTTP_SCHEMES, repeat=2) + ), + # http/https → data/file/ftp/s3/foo does not redirect + *( + ( + f"{input_scheme}://example.com/a", + f"{output_scheme}://example.com/b", + None, + ) + for input_scheme in HTTP_SCHEMES + for output_scheme in NON_HTTP_SCHEMES + ), + # http/https → relative redirects + *( + ( + f"{scheme}://example.com/a", + location, + f"{scheme}://example.com/b", + ) + for scheme in HTTP_SCHEMES + for location in ("//example.com/b", "/b") + ), + # Note: We do not test data/file/ftp/s3 schemes for the initial URL + # because their download handlers cannot return a status code of 3xx. +) + + +@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) + if target is None: + assert redirect == response + else: + assert isinstance(redirect, Request) + assert redirect.url == target + + +def meta_refresh_body(url, interval=5): + html = f"""""" + return html.encode("utf-8") + + +class MetaRefreshMiddlewareTest(Base.Test): + mwcls = MetaRefreshMiddleware + reason = "meta refresh" -class MetaRefreshMiddlewareTest(unittest.TestCase): def setUp(self): crawler = get_crawler(Spider) self.spider = crawler._create_spider("foo") - self.mw = MetaRefreshMiddleware.from_crawler(crawler) + self.mw = self.mwcls.from_crawler(crawler) def _body(self, interval=5, url="http://example.org/newpage"): - html = f"""""" - return html.encode("utf-8") + return meta_refresh_body(url, interval) - def test_priority_adjust(self): - req = Request("http://a.com") - rsp = HtmlResponse(req.url, body=self._body()) - req2 = self.mw.process_response(req, rsp, self.spider) - assert req2.priority > req.priority + def get_response(self, request, location): + return HtmlResponse(request.url, body=self._body(url=location)) def test_meta_refresh(self): req = Request(url="http://example.org") @@ -301,62 +1251,6 @@ class MetaRefreshMiddlewareTest(unittest.TestCase): ), "Content-Length header must not be present in redirected request" assert not req2.body, f"Redirected body must be empty, not '{req2.body}'" - def test_max_redirect_times(self): - self.mw.max_redirect_times = 1 - req = Request("http://scrapytest.org/max") - rsp = HtmlResponse(req.url, body=self._body()) - - req = self.mw.process_response(req, rsp, self.spider) - assert isinstance(req, Request) - assert "redirect_times" in req.meta - self.assertEqual(req.meta["redirect_times"], 1) - self.assertRaises( - IgnoreRequest, self.mw.process_response, req, rsp, self.spider - ) - - def test_ttl(self): - self.mw.max_redirect_times = 100 - req = Request("http://scrapytest.org/302", meta={"redirect_ttl": 1}) - rsp = HtmlResponse(req.url, body=self._body()) - - req = self.mw.process_response(req, rsp, self.spider) - assert isinstance(req, Request) - self.assertRaises( - IgnoreRequest, self.mw.process_response, req, rsp, self.spider - ) - - def test_redirect_urls(self): - req1 = Request("http://scrapytest.org/first") - rsp1 = HtmlResponse(req1.url, body=self._body(url="/redirected")) - req2 = self.mw.process_response(req1, rsp1, self.spider) - assert isinstance(req2, Request), req2 - rsp2 = HtmlResponse(req2.url, body=self._body(url="/redirected2")) - req3 = self.mw.process_response(req2, rsp2, self.spider) - assert isinstance(req3, Request), req3 - self.assertEqual(req2.url, "http://scrapytest.org/redirected") - self.assertEqual(req2.meta["redirect_urls"], ["http://scrapytest.org/first"]) - self.assertEqual(req3.url, "http://scrapytest.org/redirected2") - self.assertEqual( - req3.meta["redirect_urls"], - ["http://scrapytest.org/first", "http://scrapytest.org/redirected"], - ) - - def test_redirect_reasons(self): - req1 = Request("http://scrapytest.org/first") - rsp1 = HtmlResponse( - "http://scrapytest.org/first", body=self._body(url="/redirected") - ) - req2 = self.mw.process_response(req1, rsp1, self.spider) - rsp2 = HtmlResponse( - "http://scrapytest.org/redirected", body=self._body(url="/redirected1") - ) - req3 = self.mw.process_response(req2, rsp2, self.spider) - - self.assertEqual(req2.meta["redirect_reasons"], ["meta refresh"]) - self.assertEqual( - req3.meta["redirect_reasons"], ["meta refresh", "meta refresh"] - ) - def test_ignore_tags_default(self): req = Request(url="http://example.org") body = ( @@ -364,9 +1258,8 @@ class MetaRefreshMiddlewareTest(unittest.TestCase): """content="0;URL='http://example.org/newpage'">""" ) rsp = HtmlResponse(req.url, body=body.encode()) - req2 = self.mw.process_response(req, rsp, self.spider) - assert isinstance(req2, Request) - self.assertEqual(req2.url, "http://example.org/newpage") + response = self.mw.process_response(req, rsp, self.spider) + assert isinstance(response, Response) def test_ignore_tags_1_x_list(self): """Test that Scrapy 1.x behavior remains possible""" @@ -383,5 +1276,45 @@ class MetaRefreshMiddlewareTest(unittest.TestCase): assert isinstance(response, Response) +@pytest.mark.parametrize( + SCHEME_PARAMS, + ( + *REDIRECT_SCHEME_CASES, + # data/file/ftp/s3/foo → * does not redirect + *( + ( + f"{input_scheme}://example.com/a", + f"{output_scheme}://example.com/b", + None, + ) + for input_scheme in NON_HTTP_SCHEMES + for output_scheme in chain(HTTP_SCHEMES, NON_HTTP_SCHEMES) + ), + # data/file/ftp/s3/foo → relative does not redirect + *( + ( + f"{scheme}://example.com/a", + location, + None, + ) + for scheme in NON_HTTP_SCHEMES + for location in ("//example.com/b", "/b") + ), + ), +) +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) + if target is None: + assert redirect == response + else: + assert isinstance(redirect, Request) + assert redirect.url == target + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_downloadermiddleware_robotstxt.py b/tests/test_downloadermiddleware_robotstxt.py index 26898a6a1..12b541456 100644 --- a/tests/test_downloadermiddleware_robotstxt.py +++ b/tests/test_downloadermiddleware_robotstxt.py @@ -11,7 +11,7 @@ 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 tests.test_robotstxt_interface import reppy_available, rerp_available +from tests.test_robotstxt_interface import rerp_available class RobotsTxtMiddlewareTest(unittest.TestCase): @@ -40,9 +40,7 @@ Disallow: /wiki/K%C3%A4ytt%C3%A4j%C3%A4: Disallow: /wiki/Käyttäjä: User-Agent: UnicödeBöt Disallow: /some/randome/page.html -""".encode( - "utf-8" - ) +""".encode() response = TextResponse("http://site.local/robots.txt", body=ROBOTS) def return_response(request): @@ -256,14 +254,3 @@ class RobotsTxtMiddlewareWithRerpTest(RobotsTxtMiddlewareTest): self.crawler.settings.set( "ROBOTSTXT_PARSER", "scrapy.robotstxt.RerpRobotParser" ) - - -class RobotsTxtMiddlewareWithReppyTest(RobotsTxtMiddlewareTest): - if not reppy_available(): - skip = "Reppy parser is not installed" - - def setUp(self): - super().setUp() - self.crawler.settings.set( - "ROBOTSTXT_PARSER", "scrapy.robotstxt.ReppyRobotParser" - ) diff --git a/tests/test_downloaderslotssettings.py b/tests/test_downloaderslotssettings.py index 9d4072d19..ea8c5b4f0 100644 --- a/tests/test_downloaderslotssettings.py +++ b/tests/test_downloaderslotssettings.py @@ -3,8 +3,10 @@ import time from twisted.internet import defer from twisted.trial.unittest import TestCase +from scrapy import Request +from scrapy.core.downloader import Downloader, Slot from scrapy.crawler import CrawlerRunner -from scrapy.http import Request +from scrapy.utils.test import get_crawler from tests.mockserver import MockServer from tests.spiders import MetaSpider @@ -20,6 +22,7 @@ class DownloaderSlotsSettingsTestSpider(MetaSpider): "concurrency": 1, "delay": 2, "randomize_delay": False, + "throttle": False, }, "books.toscrape.com": {"delay": 3, "randomize_delay": False}, }, @@ -70,3 +73,27 @@ class CrawlTestCase(TestCase): } self.assertTrue(max(list(error_delta.values())) < tolerance) + + +def test_params(): + params = { + "concurrency": 1, + "delay": 2, + "randomize_delay": False, + "throttle": False, + } + settings = { + "DOWNLOAD_SLOTS": { + "example.com": params, + }, + } + crawler = get_crawler(settings_dict=settings) + downloader = Downloader(crawler) + downloader._slot_gc_loop.stop() # Prevent an unclean reactor. + request = Request("https://example.com") + _, actual = downloader._get_slot(request, spider=None) + expected = Slot(**params) + for param in params: + assert getattr(expected, param) == getattr( + actual, param + ), f"Slot.{param}: {getattr(expected, param)!r} != {getattr(actual, param)!r}" diff --git a/tests/test_dupefilters.py b/tests/test_dupefilters.py index aa0975555..f617fc027 100644 --- a/tests/test_dupefilters.py +++ b/tests/test_dupefilters.py @@ -146,7 +146,7 @@ class RFPDupeFilterTest(unittest.TestCase): case_insensitive_dupefilter.close("finished") def test_seenreq_newlines(self): - """Checks against adding duplicate \r to + r"""Checks against adding duplicate \r to line endings on Windows platforms.""" r1 = Request("http://scrapytest.org/1") diff --git a/tests/test_engine.py b/tests/test_engine.py index 8d7afb6a1..2ebc0b5e4 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -15,8 +15,10 @@ 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 urllib.parse import urlparse import attr @@ -27,11 +29,13 @@ from twisted.trial import unittest from twisted.web import server, static, util from scrapy import signals -from scrapy.core.engine import ExecutionEngine -from scrapy.exceptions import CloseSpider +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.item import Field, Item from scrapy.linkextractors import LinkExtractor +from scrapy.signals import request_scheduled from scrapy.spiders import Spider from scrapy.utils.signal import disconnect_all from scrapy.utils.test import get_crawler @@ -455,7 +459,7 @@ class EngineTest(unittest.TestCase): def kill_proc(): p.kill() p.communicate() - assert False, "Command took too much time to complete" + raise AssertionError("Command took too much time to complete") timer = Timer(15, kill_proc) try: @@ -467,6 +471,37 @@ class EngineTest(unittest.TestCase): self.assertNotIn(b"Traceback", stderr) +def test_request_scheduled_signal(caplog): + class TestScheduler(BaseScheduler): + def __init__(self): + self.enqueued = [] + + def enqueue_request(self, request: Request) -> bool: + self.enqueued.append(request) + return True + + def signal_handler(request: Request, spider: Spider) -> None: + if "drop" in request.url: + raise IgnoreRequest + + spider = TestSpider() + crawler = get_crawler(spider.__class__) + engine = ExecutionEngine(crawler, lambda _: None) + engine.downloader._slot_gc_loop.stop() + scheduler = TestScheduler() + engine.slot = Slot((), None, Mock(), scheduler) + crawler.signals.connect(signal_handler, request_scheduled) + keep_request = Request("https://keep.example") + engine._schedule_request(keep_request, spider) + drop_request = Request("https://drop.example") + caplog.set_level(DEBUG) + engine._schedule_request(drop_request, spider) + assert scheduler.enqueued == [ + keep_request + ], f"{scheduler.enqueued!r} != [{keep_request!r}]" + crawler.signals.disconnect(signal_handler, request_scheduled) + + if __name__ == "__main__": if len(sys.argv) > 1 and sys.argv[1] == "runserver": start_test_site(debug=True) diff --git a/tests/test_exporters.py b/tests/test_exporters.py index c11913365..fa9389044 100644 --- a/tests/test_exporters.py +++ b/tests/test_exporters.py @@ -121,7 +121,9 @@ class BaseItemExporterTest(unittest.TestCase): self.assertEqual(name, "John\xa3") ie = self._get_exporter(fields_to_export={"name": "名稱"}) - self.assertEqual(list(ie._get_serialized_fields(self.i)), [("名稱", "John\xa3")]) + self.assertEqual( + list(ie._get_serialized_fields(self.i)), [("名稱", "John\xa3")] + ) def test_field_custom_serializer(self): i = self.custom_field_item_class(name="John\xa3", age="22") @@ -150,7 +152,7 @@ class PythonItemExporterTest(BaseItemExporterTest): def test_nested_item(self): i1 = self.item_class(name="Joseph", age="22") - i2 = dict(name="Maria", age=i1) + i2 = {"name": "Maria", "age": i1} i3 = self.item_class(name="Jesus", age=i2) ie = self._get_exporter() exported = ie.export_item(i3) @@ -183,7 +185,7 @@ class PythonItemExporterTest(BaseItemExporterTest): def test_export_item_dict_list(self): i1 = self.item_class(name="Joseph", age="22") - i2 = dict(name="Maria", age=[i1]) + i2 = {"name": "Maria", "age": [i1]} i3 = self.item_class(name="Jesus", age=[i2]) ie = self._get_exporter() exported = ie.export_item(i3) @@ -371,7 +373,7 @@ class CsvItemExporterTest(BaseItemExporterTest): def test_join_multivalue_not_strings(self): self.assertExportResult( - item=dict(name="John", friends=[4, 8]), + item={"name": "John", "friends": [4, 8]}, include_headers_line=False, expected='"[4, 8]",John\r\n', ) @@ -386,14 +388,14 @@ class CsvItemExporterTest(BaseItemExporterTest): def test_errors_default(self): with self.assertRaises(UnicodeEncodeError): self.assertExportResult( - item=dict(text="W\u0275\u200Brd"), + item={"text": "W\u0275\u200Brd"}, expected=None, encoding="windows-1251", ) def test_errors_xmlcharrefreplace(self): self.assertExportResult( - item=dict(text="W\u0275\u200Brd"), + item={"text": "W\u0275\u200Brd"}, include_headers_line=False, expected="Wɵ​rd\r\n", encoding="windows-1251", @@ -453,8 +455,8 @@ class XmlItemExporterTest(BaseItemExporterTest): ) def test_nested_item(self): - i1 = dict(name="foo\xa3hoo", age="22") - i2 = dict(name="bar", age=i1) + i1 = {"name": "foo\xa3hoo", "age": "22"} + i2 = {"name": "bar", "age": i1} i3 = self.item_class(name="buz", age=i2) self.assertExportResult( @@ -476,8 +478,8 @@ class XmlItemExporterTest(BaseItemExporterTest): ) def test_nested_list_item(self): - i1 = dict(name="foo") - i2 = dict(name="bar", v2={"egg": ["spam"]}) + i1 = {"name": "foo"} + i2 = {"name": "bar", "v2": {"egg": ["spam"]}} i3 = self.item_class(name="buz", age=[i1, i2]) self.assertExportResult( @@ -532,7 +534,7 @@ class JsonLinesItemExporterTest(BaseItemExporterTest): def test_nested_item(self): i1 = self.item_class(name="Joseph", age="22") - i2 = dict(name="Maria", age=i1) + i2 = {"name": "Maria", "age": i1} i3 = self.item_class(name="Jesus", age=i2) self.ie.start_exporting() self.ie.export_item(i3) @@ -620,9 +622,9 @@ class JsonItemExporterTest(JsonLinesItemExporterTest): self.assertEqual(exported, [expected]) def test_nested_dict_item(self): - i1 = dict(name="Joseph\xa3", age="22") + i1 = {"name": "Joseph\xa3", "age": "22"} i2 = self.item_class(name="Maria", age=i1) - i3 = dict(name="Jesus", age=i2) + i3 = {"name": "Jesus", "age": i2} self.ie.start_exporting() self.ie.export_item(i3) self.ie.finish_exporting() diff --git a/tests/test_extension_throttle.py b/tests/test_extension_throttle.py new file mode 100644 index 000000000..722a05c26 --- /dev/null +++ b/tests/test_extension_throttle.py @@ -0,0 +1,343 @@ +from logging import INFO +from unittest.mock import Mock + +import pytest + +from scrapy import Request, Spider +from scrapy.exceptions import NotConfigured +from scrapy.extensions.throttle import AutoThrottle +from scrapy.http.response import Response +from scrapy.settings.default_settings import ( + AUTOTHROTTLE_MAX_DELAY, + AUTOTHROTTLE_START_DELAY, + DOWNLOAD_DELAY, +) +from scrapy.utils.misc import build_from_crawler +from scrapy.utils.test import get_crawler as _get_crawler + +UNSET = object() + + +class TestSpider(Spider): + name = "test" + + +def get_crawler(settings=None, spidercls=None): + settings = settings or {} + settings["AUTOTHROTTLE_ENABLED"] = True + return _get_crawler(settings_dict=settings, spidercls=spidercls) + + +@pytest.mark.parametrize( + ("value", "expected"), + ( + (UNSET, False), + (False, False), + (True, True), + ), +) +def test_enabled(value, expected): + settings = {} + if value is not UNSET: + settings["AUTOTHROTTLE_ENABLED"] = value + crawler = _get_crawler(settings_dict=settings) + if expected: + build_from_crawler(AutoThrottle, crawler) + else: + with pytest.raises(NotConfigured): + build_from_crawler(AutoThrottle, crawler) + + +@pytest.mark.parametrize( + "value", + ( + 0.0, + -1.0, + ), +) +def test_target_concurrency_invalid(value): + settings = {"AUTOTHROTTLE_TARGET_CONCURRENCY": value} + crawler = get_crawler(settings) + with pytest.raises(NotConfigured): + build_from_crawler(AutoThrottle, crawler) + + +@pytest.mark.parametrize( + ("spider", "setting", "expected"), + ( + (UNSET, UNSET, DOWNLOAD_DELAY), + (1.0, UNSET, 1.0), + (UNSET, 1.0, 1.0), + (1.0, 2.0, 1.0), + (3.0, 2.0, 3.0), + ), +) +def test_mindelay_definition(spider, setting, expected): + settings = {} + if setting is not UNSET: + settings["DOWNLOAD_DELAY"] = setting + + class _TestSpider(Spider): + name = "test" + + if spider is not UNSET: + _TestSpider.download_delay = spider + + crawler = get_crawler(settings, _TestSpider) + at = build_from_crawler(AutoThrottle, crawler) + at._spider_opened(_TestSpider()) + assert at.mindelay == expected + + +@pytest.mark.parametrize( + ("value", "expected"), + ( + (UNSET, AUTOTHROTTLE_MAX_DELAY), + (1.0, 1.0), + ), +) +def test_maxdelay_definition(value, expected): + settings = {} + if value is not UNSET: + settings["AUTOTHROTTLE_MAX_DELAY"] = value + crawler = get_crawler(settings) + at = build_from_crawler(AutoThrottle, crawler) + at._spider_opened(TestSpider()) + assert at.maxdelay == expected + + +@pytest.mark.parametrize( + ("min_spider", "min_setting", "start_setting", "expected"), + ( + (UNSET, UNSET, UNSET, AUTOTHROTTLE_START_DELAY), + (AUTOTHROTTLE_START_DELAY - 1.0, UNSET, UNSET, AUTOTHROTTLE_START_DELAY), + (AUTOTHROTTLE_START_DELAY + 1.0, UNSET, UNSET, AUTOTHROTTLE_START_DELAY + 1.0), + (UNSET, AUTOTHROTTLE_START_DELAY - 1.0, UNSET, AUTOTHROTTLE_START_DELAY), + (UNSET, AUTOTHROTTLE_START_DELAY + 1.0, UNSET, AUTOTHROTTLE_START_DELAY + 1.0), + (UNSET, UNSET, AUTOTHROTTLE_START_DELAY - 1.0, AUTOTHROTTLE_START_DELAY - 1.0), + (UNSET, UNSET, AUTOTHROTTLE_START_DELAY + 1.0, AUTOTHROTTLE_START_DELAY + 1.0), + ( + AUTOTHROTTLE_START_DELAY + 1.0, + AUTOTHROTTLE_START_DELAY + 2.0, + UNSET, + AUTOTHROTTLE_START_DELAY + 1.0, + ), + ( + AUTOTHROTTLE_START_DELAY + 2.0, + UNSET, + AUTOTHROTTLE_START_DELAY + 1.0, + AUTOTHROTTLE_START_DELAY + 2.0, + ), + ( + AUTOTHROTTLE_START_DELAY + 1.0, + UNSET, + AUTOTHROTTLE_START_DELAY + 2.0, + AUTOTHROTTLE_START_DELAY + 2.0, + ), + ), +) +def test_startdelay_definition(min_spider, min_setting, start_setting, expected): + settings = {} + if min_setting is not UNSET: + settings["DOWNLOAD_DELAY"] = min_setting + if start_setting is not UNSET: + settings["AUTOTHROTTLE_START_DELAY"] = start_setting + + class _TestSpider(Spider): + name = "test" + + if min_spider is not UNSET: + _TestSpider.download_delay = min_spider + + crawler = get_crawler(settings, _TestSpider) + at = build_from_crawler(AutoThrottle, crawler) + spider = _TestSpider() + at._spider_opened(spider) + assert spider.download_delay == expected + + +@pytest.mark.parametrize( + ("meta", "slot", "throttle"), + ( + ({}, None, None), + ({"download_latency": 1.0}, None, None), + ({"download_slot": "foo"}, None, None), + ({"download_slot": "foo"}, "foo", None), + ({"download_latency": 1.0, "download_slot": "foo"}, None, None), + ({"download_latency": 1.0, "download_slot": "foo"}, "foo", False), + ), +) +def test_skipped(meta, slot, throttle): + crawler = get_crawler() + at = build_from_crawler(AutoThrottle, crawler) + spider = TestSpider() + at._spider_opened(spider) + request = Request("https://example.com", meta=meta) + + crawler.engine = Mock() + crawler.engine.downloader = Mock() + crawler.engine.downloader.slots = {} + if slot is not None: + _slot = Mock() + _slot.throttle = throttle + crawler.engine.downloader.slots[slot] = _slot + at._adjust_delay = None # Raise exception if called. + + at._response_downloaded(None, request, spider) + + +@pytest.mark.parametrize( + ("download_latency", "target_concurrency", "slot_delay", "expected"), + ( + (2.0, 2.0, 1.0, 1.0), + (1.0, 2.0, 1.0, 0.75), + (4.0, 2.0, 1.0, 2.0), + (2.0, 1.0, 1.0, 2.0), + (2.0, 4.0, 1.0, 0.75), + (2.0, 2.0, 0.5, 1.0), + (2.0, 2.0, 2.0, 1.5), + ), +) +def test_adjustment(download_latency, target_concurrency, slot_delay, expected): + settings = {"AUTOTHROTTLE_TARGET_CONCURRENCY": target_concurrency} + crawler = get_crawler(settings) + at = build_from_crawler(AutoThrottle, crawler) + spider = TestSpider() + at._spider_opened(spider) + meta = {"download_latency": download_latency, "download_slot": "foo"} + request = Request("https://example.com", meta=meta) + response = Response(request.url) + + crawler.engine = Mock() + crawler.engine.downloader = Mock() + crawler.engine.downloader.slots = {} + slot = Mock() + slot.delay = slot_delay + crawler.engine.downloader.slots["foo"] = slot + + at._response_downloaded(response, request, spider) + + assert slot.delay == expected, f"{slot.delay} != {expected}" + + +@pytest.mark.parametrize( + ("mindelay", "maxdelay", "expected"), + ( + (0.5, 2.0, 1.0), + (0.25, 0.5, 0.5), + (2.0, 4.0, 2.0), + ), +) +def test_adjustment_limits(mindelay, maxdelay, expected): + download_latency, target_concurrency, slot_delay = (2.0, 2.0, 1.0) + # expected adjustment without limits with these values: 1.0 + settings = { + "AUTOTHROTTLE_MAX_DELAY": maxdelay, + "AUTOTHROTTLE_TARGET_CONCURRENCY": target_concurrency, + "DOWNLOAD_DELAY": mindelay, + } + crawler = get_crawler(settings) + at = build_from_crawler(AutoThrottle, crawler) + spider = TestSpider() + at._spider_opened(spider) + meta = {"download_latency": download_latency, "download_slot": "foo"} + request = Request("https://example.com", meta=meta) + response = Response(request.url) + + crawler.engine = Mock() + crawler.engine.downloader = Mock() + crawler.engine.downloader.slots = {} + slot = Mock() + slot.delay = slot_delay + crawler.engine.downloader.slots["foo"] = slot + + at._response_downloaded(response, request, spider) + + assert slot.delay == expected, f"{slot.delay} != {expected}" + + +@pytest.mark.parametrize( + ("download_latency", "target_concurrency", "slot_delay", "expected"), + ( + (2.0, 2.0, 1.0, 1.0), + (1.0, 2.0, 1.0, 1.0), # Instead of 0.75 + (4.0, 2.0, 1.0, 2.0), + ), +) +def test_adjustment_bad_response( + download_latency, target_concurrency, slot_delay, expected +): + settings = {"AUTOTHROTTLE_TARGET_CONCURRENCY": target_concurrency} + crawler = get_crawler(settings) + at = build_from_crawler(AutoThrottle, crawler) + spider = TestSpider() + at._spider_opened(spider) + meta = {"download_latency": download_latency, "download_slot": "foo"} + request = Request("https://example.com", meta=meta) + response = Response(request.url, status=400) + + crawler.engine = Mock() + crawler.engine.downloader = Mock() + crawler.engine.downloader.slots = {} + slot = Mock() + slot.delay = slot_delay + crawler.engine.downloader.slots["foo"] = slot + + at._response_downloaded(response, request, spider) + + assert slot.delay == expected, f"{slot.delay} != {expected}" + + +def test_debug(caplog): + settings = {"AUTOTHROTTLE_DEBUG": True} + crawler = get_crawler(settings) + at = build_from_crawler(AutoThrottle, crawler) + spider = TestSpider() + at._spider_opened(spider) + meta = {"download_latency": 1.0, "download_slot": "foo"} + request = Request("https://example.com", meta=meta) + response = Response(request.url, body=b"foo") + + crawler.engine = Mock() + crawler.engine.downloader = Mock() + crawler.engine.downloader.slots = {} + slot = Mock() + slot.delay = 2.0 + slot.transferring = (None, None) + crawler.engine.downloader.slots["foo"] = slot + + caplog.clear() + with caplog.at_level(INFO): + at._response_downloaded(response, request, spider) + + assert caplog.record_tuples == [ + ( + "scrapy.extensions.throttle", + INFO, + "slot: foo | conc: 2 | delay: 1500 ms (-500) | latency: 1000 ms | size: 3 bytes", + ), + ] + + +def test_debug_disabled(caplog): + crawler = get_crawler() + at = build_from_crawler(AutoThrottle, crawler) + spider = TestSpider() + at._spider_opened(spider) + meta = {"download_latency": 1.0, "download_slot": "foo"} + request = Request("https://example.com", meta=meta) + response = Response(request.url, body=b"foo") + + crawler.engine = Mock() + crawler.engine.downloader = Mock() + crawler.engine.downloader.slots = {} + slot = Mock() + slot.delay = 2.0 + slot.transferring = (None, None) + crawler.engine.downloader.slots["foo"] = slot + + caplog.clear() + with caplog.at_level(INFO): + at._response_downloaded(response, request, spider) + + assert caplog.record_tuples == [] diff --git a/tests/test_feedexport.py b/tests/test_feedexport.py index 277555608..f59412ab4 100644 --- a/tests/test_feedexport.py +++ b/tests/test_feedexport.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import bz2 import csv import gzip @@ -14,10 +16,9 @@ from collections import defaultdict from contextlib import ExitStack from io import BytesIO from logging import getLogger -from os import PathLike from pathlib import Path from string import ascii_letters, digits -from typing import Union +from typing import TYPE_CHECKING from unittest import mock from urllib.parse import quote, urljoin from urllib.request import pathname2url @@ -53,6 +54,9 @@ from scrapy.utils.test import get_crawler, mock_google_cloud_storage, skip_if_no from tests.mockserver import MockFTPServer, MockServer from tests.spiders import ItemSpider +if TYPE_CHECKING: + from os import PathLike + def path_to_url(path): return urljoin("file:", pathname2url(str(path))) @@ -62,7 +66,7 @@ def printf_escape(string): return string.replace("%", "%%") -def build_url(path: Union[str, PathLike]) -> str: +def build_url(path: str | PathLike) -> str: path_str = str(path) if path_str[0] != "/": path_str = "/" + path_str @@ -1356,16 +1360,16 @@ class FeedExportTest(FeedExportTestBase): @defer.inlineCallbacks def test_export_encoding(self): - items = [dict({"foo": "Test\xd6"})] + items = [{"foo": "Test\xd6"}] formats = { - "json": '[{"foo": "Test\\u00d6"}]'.encode("utf-8"), - "jsonlines": '{"foo": "Test\\u00d6"}\n'.encode("utf-8"), + "json": b'[{"foo": "Test\\u00d6"}]', + "jsonlines": b'{"foo": "Test\\u00d6"}\n', "xml": ( '\n' "Test\xd6" - ).encode("utf-8"), - "csv": "foo\r\nTest\xd6\r\n".encode("utf-8"), + ).encode(), + "csv": "foo\r\nTest\xd6\r\n".encode(), } for fmt, expected in formats.items(): @@ -1379,13 +1383,13 @@ class FeedExportTest(FeedExportTestBase): self.assertEqual(expected, data[fmt]) formats = { - "json": '[{"foo": "Test\xd6"}]'.encode("latin-1"), - "jsonlines": '{"foo": "Test\xd6"}\n'.encode("latin-1"), + "json": b'[{"foo": "Test\xd6"}]', + "jsonlines": b'{"foo": "Test\xd6"}\n', "xml": ( - '\n' - "Test\xd6" - ).encode("latin-1"), - "csv": "foo\r\nTest\xd6\r\n".encode("latin-1"), + b'\n' + b"Test\xd6" + ), + "csv": b"foo\r\nTest\xd6\r\n", } for fmt, expected in formats.items(): @@ -1401,15 +1405,15 @@ class FeedExportTest(FeedExportTestBase): @defer.inlineCallbacks def test_export_multiple_configs(self): - items = [dict({"foo": "FOO", "bar": "BAR"})] + items = [{"foo": "FOO", "bar": "BAR"}] formats = { - "json": '[\n{"bar": "BAR"}\n]'.encode("utf-8"), + "json": b'[\n{"bar": "BAR"}\n]', "xml": ( - '\n' - "\n \n FOO\n \n" - ).encode("latin-1"), - "csv": "bar,foo\r\nBAR,FOO\r\n".encode("utf-8"), + b'\n' + b"\n \n FOO\n \n" + ), + "csv": b"bar,foo\r\nBAR,FOO\r\n", } settings = { @@ -1663,8 +1667,8 @@ class FeedExportTest(FeedExportTestBase): def test_extend_kwargs(self): items = [{"foo": "FOO", "bar": "BAR"}] - expected_with_title_csv = "foo,bar\r\nFOO,BAR\r\n".encode("utf-8") - expected_without_title_csv = "FOO,BAR\r\n".encode("utf-8") + expected_with_title_csv = b"foo,bar\r\nFOO,BAR\r\n" + expected_without_title_csv = b"FOO,BAR\r\n" test_cases = [ # with title { @@ -1731,6 +1735,7 @@ class FeedExportTest(FeedExportTestBase): def store(self, file): Storage.store_file = file + Storage.file_was_closed = file.closed file.close() settings = { @@ -1746,6 +1751,7 @@ class FeedExportTest(FeedExportTestBase): } yield self.exported_no_data(settings) self.assertIs(Storage.open_file, Storage.store_file) + self.assertFalse(Storage.file_was_closed) class FeedPostProcessedExportsTest(FeedExportTestBase): @@ -2511,28 +2517,28 @@ class BatchDeliveriesTest(FeedExportTestBase): @defer.inlineCallbacks def test_export_multiple_configs(self): items = [ - dict({"foo": "FOO", "bar": "BAR"}), - dict({"foo": "FOO1", "bar": "BAR1"}), + {"foo": "FOO", "bar": "BAR"}, + {"foo": "FOO1", "bar": "BAR1"}, ] formats = { "json": [ - '[\n{"bar": "BAR"}\n]'.encode("utf-8"), - '[\n{"bar": "BAR1"}\n]'.encode("utf-8"), + b'[\n{"bar": "BAR"}\n]', + b'[\n{"bar": "BAR1"}\n]', ], "xml": [ ( - '\n' - "\n \n FOO\n \n" - ).encode("latin-1"), + b'\n' + b"\n \n FOO\n \n" + ), ( - '\n' - "\n \n FOO1\n \n" - ).encode("latin-1"), + b'\n' + b"\n \n FOO1\n \n" + ), ], "csv": [ - "foo,bar\r\nFOO,BAR\r\n".encode("utf-8"), - "foo,bar\r\nFOO1,BAR1\r\n".encode("utf-8"), + b"foo,bar\r\nFOO,BAR\r\n", + b"foo,bar\r\nFOO1,BAR1\r\n", ], } @@ -2572,11 +2578,11 @@ class BatchDeliveriesTest(FeedExportTestBase): @defer.inlineCallbacks def test_batch_item_count_feeds_setting(self): - items = [dict({"foo": "FOO"}), dict({"foo": "FOO1"})] + items = [{"foo": "FOO"}, {"foo": "FOO1"}] formats = { "json": [ - '[{"foo": "FOO"}]'.encode("utf-8"), - '[{"foo": "FOO1"}]'.encode("utf-8"), + b'[{"foo": "FOO"}]', + b'[{"foo": "FOO1"}]', ], } settings = { diff --git a/tests/test_http2_client_protocol.py b/tests/test_http2_client_protocol.py index 8fdf3d56f..1f998de1a 100644 --- a/tests/test_http2_client_protocol.py +++ b/tests/test_http2_client_protocol.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import json import random import re @@ -5,7 +7,8 @@ import shutil import string from ipaddress import IPv4Address from pathlib import Path -from typing import Dict +from tempfile import mkdtemp +from typing import TYPE_CHECKING from unittest import mock, skipIf from urllib.parse import urlencode @@ -19,7 +22,6 @@ from twisted.internet.defer import ( from twisted.internet.endpoints import SSL4ClientEndpoint, SSL4ServerEndpoint from twisted.internet.error import TimeoutError from twisted.internet.ssl import Certificate, PrivateCertificate, optionsForClientTLS -from twisted.python.failure import Failure from twisted.trial.unittest import TestCase from twisted.web.client import URI, ResponseFailed from twisted.web.http import H2_ENABLED @@ -32,6 +34,9 @@ from scrapy.settings import Settings from scrapy.spiders import Spider from tests.mockserver import LeafResource, Status, ssl_context_factory +if TYPE_CHECKING: + from twisted.python.failure import Failure + def generate_random_string(size): return "".join(random.choices(string.ascii_uppercase + string.digits, k=size)) @@ -147,7 +152,7 @@ class QueryParams(LeafResource): request.setHeader("Content-Type", "application/json; charset=UTF-8") request.setHeader("Content-Encoding", "UTF-8") - query_params: Dict[str, str] = {} + query_params: dict[str, str] = {} assert request.args is not None for k, v in request.args.items(): query_params[str(k, "utf-8")] = str(v[0], "utf-8") @@ -185,8 +190,7 @@ class Https2ClientProtocolTestCase(TestCase): certificate_file = Path(__file__).parent / "keys" / "localhost.crt" def _init_resource(self): - self.temp_directory = self.mktemp() - Path(self.temp_directory).mkdir() + self.temp_directory = mkdtemp() r = File(self.temp_directory) r.putChild(b"get-data-html-small", GetDataHtmlSmall()) r.putChild(b"get-data-html-large", GetDataHtmlLarge()) diff --git a/tests/test_http_cookies.py b/tests/test_http_cookies.py index 9e43b72b0..932644320 100644 --- a/tests/test_http_cookies.py +++ b/tests/test_http_cookies.py @@ -1,8 +1,8 @@ from unittest import TestCase -from urllib.parse import urlparse from scrapy.http import Request, Response from scrapy.http.cookies import WrappedRequest, WrappedResponse +from scrapy.utils.httpobj import urlparse_cached class WrappedRequestTest(TestCase): @@ -17,12 +17,12 @@ class WrappedRequestTest(TestCase): self.assertEqual(self.wrapped.full_url, self.request.url) def test_get_host(self): - self.assertEqual(self.wrapped.get_host(), urlparse(self.request.url).netloc) - self.assertEqual(self.wrapped.host, urlparse(self.request.url).netloc) + self.assertEqual(self.wrapped.get_host(), urlparse_cached(self.request).netloc) + self.assertEqual(self.wrapped.host, urlparse_cached(self.request).netloc) def test_get_type(self): - self.assertEqual(self.wrapped.get_type(), urlparse(self.request.url).scheme) - self.assertEqual(self.wrapped.type, urlparse(self.request.url).scheme) + self.assertEqual(self.wrapped.get_type(), urlparse_cached(self.request).scheme) + self.assertEqual(self.wrapped.type, urlparse_cached(self.request).scheme) def test_is_unverifiable(self): self.assertFalse(self.wrapped.is_unverifiable()) @@ -43,6 +43,13 @@ class WrappedRequestTest(TestCase): def test_get_header(self): self.assertEqual(self.wrapped.get_header("content-type"), "text/html") self.assertEqual(self.wrapped.get_header("xxxxx", "def"), "def") + self.assertEqual(self.wrapped.get_header("xxxxx"), None) + wrapped = WrappedRequest( + Request( + "http://www.example.com/page.html", headers={"empty-binary-header": b""} + ) + ) + self.assertEqual(wrapped.get_header("empty-binary-header"), "") def test_header_items(self): self.assertEqual(self.wrapped.header_items(), [("Content-Type", ["text/html"])]) diff --git a/tests/test_http_request.py b/tests/test_http_request.py index 6dc9ec8b7..d0fb17f1f 100644 --- a/tests/test_http_request.py +++ b/tests/test_http_request.py @@ -3,9 +3,9 @@ import re import unittest import warnings import xmlrpc.client -from typing import Any, Dict, List +from typing import Any from unittest import mock -from urllib.parse import parse_qs, unquote_to_bytes, urlparse +from urllib.parse import parse_qs, unquote_to_bytes from scrapy.http import ( FormRequest, @@ -16,14 +16,15 @@ from scrapy.http import ( XmlRpcRequest, ) from scrapy.http.request import NO_CALLBACK +from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.python import to_bytes, to_unicode class RequestTest(unittest.TestCase): request_class = Request default_method = "GET" - default_headers: Dict[bytes, List[bytes]] = {} - default_meta: Dict[str, Any] = {} + default_headers: dict[bytes, list[bytes]] = {} + default_meta: dict[str, Any] = {} def test_init(self): # Request requires url in the __init__ method @@ -617,8 +618,8 @@ class FormRequestTest(RequestTest): method="GET", formdata=(("foo", "bar"), ("foo", "baz")), ) - self.assertEqual(urlparse(req.url).hostname, "www.example.com") - self.assertEqual(urlparse(req.url).query, "foo=bar&foo=baz") + self.assertEqual(urlparse_cached(req).hostname, "www.example.com") + self.assertEqual(urlparse_cached(req).query, "foo=bar&foo=baz") def test_from_response_override_duplicate_form_key(self): response = _buildresponse( @@ -666,8 +667,8 @@ class FormRequestTest(RequestTest): response, formdata={"one": ["two", "three"], "six": "seven"} ) self.assertEqual(r1.method, "GET") - self.assertEqual(urlparse(r1.url).hostname, "www.example.com") - self.assertEqual(urlparse(r1.url).path, "/this/get.php") + self.assertEqual(urlparse_cached(r1).hostname, "www.example.com") + self.assertEqual(urlparse_cached(r1).path, "/this/get.php") fs = _qs(r1) self.assertEqual(set(fs[b"test"]), {b"val1", b"val2"}) self.assertEqual(set(fs[b"one"]), {b"two", b"three"}) @@ -1426,6 +1427,58 @@ class FormRequestTest(RequestTest): r = self.request_class.from_response(response) self.assertEqual(r.method, expected) + def test_form_response_with_invalid_formdata_type_error(self): + """Test that a ValueError is raised for non-iterable and non-dict formdata input""" + response = _buildresponse( + """ + + + + """ + ) + with self.assertRaises(ValueError) as context: + FormRequest.from_response(response, formdata=123) + + self.assertIn( + "formdata should be a dict or iterable of tuples", str(context.exception) + ) + + def test_form_response_with_custom_invalid_formdata_value_error(self): + """Test that a ValueError is raised for fault-inducing iterable formdata input""" + response = _buildresponse( + """ +
+ +
+ """ + ) + + with self.assertRaises(ValueError) as context: + FormRequest.from_response(response, formdata=("a",)) + + self.assertIn( + "formdata should be a dict or iterable of tuples", str(context.exception) + ) + + def test_get_form_with_xpath_no_form_parent(self): + """Test that _get_from raised a ValueError when an XPath selects an element + not nested within a
and no parent is found""" + response = _buildresponse( + """ +
+

This paragraph is not inside a form.

+
+ + +
+ """ + ) + + with self.assertRaises(ValueError) as context: + FormRequest.from_response(response, formxpath='//div[@id="outside-form"]/p') + + self.assertIn("No
element found with", str(context.exception)) + def _buildresponse(body, **kwargs): kwargs.setdefault("body", body) @@ -1642,6 +1695,25 @@ class JsonRequestTest(RequestTest): self.assertEqual(kwargs["ensure_ascii"], True) self.assertEqual(kwargs["allow_nan"], True) + def test_replacement_both_body_and_data_warns(self): + """Test that we get a warning if both body and data are passed""" + body1 = None + body2 = b"body" + data1 = { + "name1": "value1", + } + data2 = { + "name2": "value2", + } + r1 = self.request_class(url="http://www.example.com/", data=data1, body=body1) + + with warnings.catch_warnings(record=True) as _warnings: + r1.replace(data=data2, body=body2) + self.assertIn( + "Both body and data passed. data will be ignored", + str(_warnings[0].message), + ) + def tearDown(self): warnings.resetwarnings() super().tearDown() diff --git a/tests/test_http_response.py b/tests/test_http_response.py index 80d46274b..b8a277295 100644 --- a/tests/test_http_response.py +++ b/tests/test_http_response.py @@ -728,9 +728,7 @@ class TextResponseTest(BaseResponseTest): resp1 = self.response_class( "http://example.com", encoding="utf8", - body='click me'.encode( - "utf8" - ), + body='click me'.encode(), ) req = self._assert_followed_url( resp1.css("a")[0], diff --git a/tests/test_item.py b/tests/test_item.py index ce2b4fd15..daf5d4f59 100644 --- a/tests/test_item.py +++ b/tests/test_item.py @@ -290,7 +290,9 @@ class ItemMetaTest(unittest.TestCase): class ItemMetaClassCellRegression(unittest.TestCase): def test_item_meta_classcell_regression(self): class MyItem(Item, metaclass=ItemMeta): - def __init__(self, *args, **kwargs): + def __init__( + self, *args, **kwargs + ): # pylint: disable=useless-parent-delegation # This call to super() trigger the __classcell__ propagation # requirement. When not done properly raises an error: # TypeError: __class__ set to diff --git a/tests/test_linkextractors.py b/tests/test_linkextractors.py index 18e9608c1..ed3394b01 100644 --- a/tests/test_linkextractors.py +++ b/tests/test_linkextractors.py @@ -1,7 +1,8 @@ +from __future__ import annotations + import pickle import re import unittest -from typing import Optional from packaging.version import Version from pytest import mark @@ -16,7 +17,7 @@ from tests import get_testdata # a hack to skip base class tests in pytest class Base: class LinkExtractorTestCase(unittest.TestCase): - extractor_cls: Optional[type] = None + extractor_cls: type | None = None def setUp(self): body = get_testdata("link_extractor", "linkextractor.html") @@ -37,7 +38,7 @@ class Base: page4_url = "http://example.com/page%204.html" self.assertEqual( - [link for link in lx.extract_links(self.response)], + list(lx.extract_links(self.response)), [ Link(url="http://example.com/sample1.html", text=""), Link(url="http://example.com/sample2.html", text="sample 2"), @@ -55,7 +56,7 @@ class Base: def test_extract_filter_allow(self): lx = self.extractor_cls(allow=("sample",)) self.assertEqual( - [link for link in lx.extract_links(self.response)], + list(lx.extract_links(self.response)), [ Link(url="http://example.com/sample1.html", text=""), Link(url="http://example.com/sample2.html", text="sample 2"), @@ -70,7 +71,7 @@ class Base: def test_extract_filter_allow_with_duplicates(self): lx = self.extractor_cls(allow=("sample",), unique=False) self.assertEqual( - [link for link in lx.extract_links(self.response)], + list(lx.extract_links(self.response)), [ Link(url="http://example.com/sample1.html", text=""), Link(url="http://example.com/sample2.html", text="sample 2"), @@ -93,7 +94,7 @@ class Base: def test_extract_filter_allow_with_duplicates_canonicalize(self): lx = self.extractor_cls(allow=("sample",), unique=False, canonicalize=True) self.assertEqual( - [link for link in lx.extract_links(self.response)], + list(lx.extract_links(self.response)), [ Link(url="http://example.com/sample1.html", text=""), Link(url="http://example.com/sample2.html", text="sample 2"), @@ -116,7 +117,7 @@ class Base: def test_extract_filter_allow_no_duplicates_canonicalize(self): lx = self.extractor_cls(allow=("sample",), unique=True, canonicalize=True) self.assertEqual( - [link for link in lx.extract_links(self.response)], + list(lx.extract_links(self.response)), [ Link(url="http://example.com/sample1.html", text=""), Link(url="http://example.com/sample2.html", text="sample 2"), @@ -127,7 +128,7 @@ class Base: def test_extract_filter_allow_and_deny(self): lx = self.extractor_cls(allow=("sample",), deny=("3",)) self.assertEqual( - [link for link in lx.extract_links(self.response)], + list(lx.extract_links(self.response)), [ Link(url="http://example.com/sample1.html", text=""), Link(url="http://example.com/sample2.html", text="sample 2"), @@ -137,7 +138,7 @@ class Base: def test_extract_filter_allowed_domains(self): lx = self.extractor_cls(allow_domains=("google.com",)) self.assertEqual( - [link for link in lx.extract_links(self.response)], + list(lx.extract_links(self.response)), [ Link(url="http://www.google.com/something", text=""), ], @@ -148,7 +149,7 @@ class Base: lx = self.extractor_cls(allow="sample") self.assertEqual( - [link for link in lx.extract_links(self.response)], + list(lx.extract_links(self.response)), [ Link(url="http://example.com/sample1.html", text=""), Link(url="http://example.com/sample2.html", text="sample 2"), @@ -162,7 +163,7 @@ class Base: lx = self.extractor_cls(allow="sample", deny="3") self.assertEqual( - [link for link in lx.extract_links(self.response)], + list(lx.extract_links(self.response)), [ Link(url="http://example.com/sample1.html", text=""), Link(url="http://example.com/sample2.html", text="sample 2"), @@ -171,7 +172,7 @@ class Base: lx = self.extractor_cls(allow_domains="google.com") self.assertEqual( - [link for link in lx.extract_links(self.response)], + list(lx.extract_links(self.response)), [ Link(url="http://www.google.com/something", text=""), ], @@ -179,14 +180,14 @@ class Base: lx = self.extractor_cls(deny_domains="example.com") self.assertEqual( - [link for link in lx.extract_links(self.response)], + list(lx.extract_links(self.response)), [ Link(url="http://www.google.com/something", text=""), ], ) def test_nofollow(self): - '''Test the extractor's behaviour for links with rel="nofollow"''' + """Test the extractor's behaviour for links with rel='nofollow'""" html = b"""Page title<title> <body> @@ -265,7 +266,7 @@ class Base: def test_restrict_xpaths(self): lx = self.extractor_cls(restrict_xpaths=('//div[@id="subwrapper"]',)) self.assertEqual( - [link for link in lx.extract_links(self.response)], + list(lx.extract_links(self.response)), [ Link(url="http://example.com/sample1.html", text=""), Link(url="http://example.com/sample2.html", text="sample 2"), @@ -337,7 +338,7 @@ class Base: restrict_css=("#subwrapper + a",), ) self.assertEqual( - [link for link in lx.extract_links(self.response)], + list(lx.extract_links(self.response)), [ Link(url="http://example.com/sample1.html", text=""), Link(url="http://example.com/sample2.html", text="sample 2"), @@ -705,7 +706,7 @@ class Base: response = HtmlResponse("http://example.org/index.html", body=html) lx = self.extractor_cls() self.assertEqual( - [link for link in lx.extract_links(response)], + list(lx.extract_links(response)), [ Link( url="http://example.org/item1.html", @@ -745,6 +746,118 @@ class Base: lx = self.extractor_cls() self.assertIsInstance(pickle.loads(pickle.dumps(lx)), self.extractor_cls) + def test_link_extractor_aggregation(self): + """When a parameter like restrict_css is used, the underlying + implementation calls its internal link extractor once per selector + matching the specified restrictions, and then aggregates the + extracted links. + + Test that aggregation respects the unique and canonicalize + parameters. + """ + # unique=True (default), canonicalize=False (default) + lx = self.extractor_cls(restrict_css=("div",)) + response = HtmlResponse( + "https://example.com", + body=b""" + <div> + <a href="/a">a1</a> + <a href="/b?a=1&b=2">b1</a> + </div> + <div> + <a href="/a">a2</a> + <a href="/b?b=2&a=1">b2</a> + </div> + """, + ) + actual = lx.extract_links(response) + self.assertEqual( + actual, + [ + Link(url="https://example.com/a", text="a1"), + Link(url="https://example.com/b?a=1&b=2", text="b1"), + Link(url="https://example.com/b?b=2&a=1", text="b2"), + ], + ) + + # unique=True (default), canonicalize=True + lx = self.extractor_cls(restrict_css=("div",), canonicalize=True) + response = HtmlResponse( + "https://example.com", + body=b""" + <div> + <a href="/a">a1</a> + <a href="/b?a=1&b=2">b1</a> + </div> + <div> + <a href="/a">a2</a> + <a href="/b?b=2&a=1">b2</a> + </div> + """, + ) + actual = lx.extract_links(response) + self.assertEqual( + actual, + [ + Link(url="https://example.com/a", text="a1"), + Link(url="https://example.com/b?a=1&b=2", text="b1"), + ], + ) + + # unique=False, canonicalize=False (default) + lx = self.extractor_cls(restrict_css=("div",), unique=False) + response = HtmlResponse( + "https://example.com", + body=b""" + <div> + <a href="/a">a1</a> + <a href="/b?a=1&b=2">b1</a> + </div> + <div> + <a href="/a">a2</a> + <a href="/b?b=2&a=1">b2</a> + </div> + """, + ) + actual = lx.extract_links(response) + self.assertEqual( + actual, + [ + Link(url="https://example.com/a", text="a1"), + Link(url="https://example.com/b?a=1&b=2", text="b1"), + Link(url="https://example.com/a", text="a2"), + Link(url="https://example.com/b?b=2&a=1", text="b2"), + ], + ) + + # unique=False, canonicalize=True + lx = self.extractor_cls( + restrict_css=("div",), unique=False, canonicalize=True + ) + response = HtmlResponse( + "https://example.com", + body=b""" + <div> + <a href="/a">a1</a> + <a href="/b?a=1&b=2">b1</a> + </div> + <div> + <a href="/a">a2</a> + <a href="/b?b=2&a=1">b2</a> + </div> + """, + ) + actual = lx.extract_links(response) + self.assertEqual( + actual, + [ + Link(url="https://example.com/a", text="a1"), + Link(url="https://example.com/b?a=1&b=2", text="b1"), + Link(url="https://example.com/a", text="a2"), + Link(url="https://example.com/b?a=1&b=2", text="b2"), + ], + ) + class LxmlLinkExtractorTestCase(Base.LinkExtractorTestCase): extractor_cls = LxmlLinkExtractor @@ -758,7 +871,7 @@ class LxmlLinkExtractorTestCase(Base.LinkExtractorTestCase): response = HtmlResponse("http://example.org/index.html", body=html) lx = self.extractor_cls() self.assertEqual( - [link for link in lx.extract_links(response)], + list(lx.extract_links(response)), [ Link( url="http://example.org/item1.html", text="Item 1", nofollow=False @@ -779,7 +892,7 @@ class LxmlLinkExtractorTestCase(Base.LinkExtractorTestCase): # Simple text inclusion test lx = self.extractor_cls(restrict_text="dog") self.assertEqual( - [link for link in lx.extract_links(response)], + list(lx.extract_links(response)), [ Link( url="http://example.org/item2.html", @@ -791,7 +904,7 @@ class LxmlLinkExtractorTestCase(Base.LinkExtractorTestCase): # Unique regex test lx = self.extractor_cls(restrict_text=r"of.*dog") self.assertEqual( - [link for link in lx.extract_links(response)], + list(lx.extract_links(response)), [ Link( url="http://example.org/item2.html", @@ -803,7 +916,7 @@ class LxmlLinkExtractorTestCase(Base.LinkExtractorTestCase): # Multiple regex test lx = self.extractor_cls(restrict_text=[r"of.*dog", r"of.*cat"]) self.assertEqual( - [link for link in lx.extract_links(response)], + list(lx.extract_links(response)), [ Link( url="http://example.org/item1.html", @@ -818,9 +931,6 @@ class LxmlLinkExtractorTestCase(Base.LinkExtractorTestCase): ], ) - def test_restrict_xpaths_with_html_entities(self): - super().test_restrict_xpaths_with_html_entities() - @mark.skipif( Version(w3lib_version) < Version("2.0.0"), reason=( @@ -837,7 +947,7 @@ class LxmlLinkExtractorTestCase(Base.LinkExtractorTestCase): response = HtmlResponse("http://example.org/index.html", body=html) lx = self.extractor_cls() self.assertEqual( - [link for link in lx.extract_links(response)], + list(lx.extract_links(response)), [ Link( url="http://example.org/item2.html", @@ -851,3 +961,15 @@ class LxmlLinkExtractorTestCase(Base.LinkExtractorTestCase): ), ], ) + + def test_link_allowed_is_false_with_empty_url(self): + bad_link = Link("") + self.assertFalse(LxmlLinkExtractor()._link_allowed(bad_link)) + + def test_link_allowed_is_false_with_bad_url_prefix(self): + bad_link = Link("htp://should_be_http.example") + self.assertFalse(LxmlLinkExtractor()._link_allowed(bad_link)) + + def test_link_allowed_is_false_with_missing_url_prefix(self): + bad_link = Link("should_have_prefix.example") + self.assertFalse(LxmlLinkExtractor()._link_allowed(bad_link)) diff --git a/tests/test_loader.py b/tests/test_loader.py index b0b7f8723..aca428bbe 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -1,6 +1,7 @@ +from __future__ import annotations + import dataclasses import unittest -from typing import Optional import attr from itemadapter import ItemAdapter @@ -88,7 +89,7 @@ class BasicItemLoaderTest(unittest.TestCase): class InitializationTestMixin: - item_class: Optional[type] = None + item_class: type | None = None def test_keep_single_value(self): """Loaded item should contain values from the initial item""" @@ -156,7 +157,7 @@ class InitializationTestMixin: self.assertEqual(il.get_output_value("name"), ["foo"]) loaded_item = il.load_item() self.assertIsInstance(loaded_item, self.item_class) - self.assertEqual(ItemAdapter(loaded_item).asdict(), dict({"name": ["foo"]})) + self.assertEqual(ItemAdapter(loaded_item).asdict(), {"name": ["foo"]}) def test_get_output_value_list(self): """Getting output value must not remove value from item""" @@ -165,9 +166,7 @@ class InitializationTestMixin: self.assertEqual(il.get_output_value("name"), ["foo", "bar"]) loaded_item = il.load_item() self.assertIsInstance(loaded_item, self.item_class) - self.assertEqual( - ItemAdapter(loaded_item).asdict(), dict({"name": ["foo", "bar"]}) - ) + self.assertEqual(ItemAdapter(loaded_item).asdict(), {"name": ["foo", "bar"]}) def test_values_single(self): """Values from initial item must be added to loader._values""" diff --git a/tests/test_loader_deprecated.py b/tests/test_loader_deprecated.py index d7f773d5c..0d245bec9 100644 --- a/tests/test_loader_deprecated.py +++ b/tests/test_loader_deprecated.py @@ -526,7 +526,7 @@ class InitializationFromDictTest(unittest.TestCase): self.assertEqual(il.get_output_value("name"), ["foo"]) loaded_item = il.load_item() self.assertIsInstance(loaded_item, self.item_class) - self.assertEqual(loaded_item, dict({"name": ["foo"]})) + self.assertEqual(loaded_item, {"name": ["foo"]}) def test_get_output_value_list(self): """Getting output value must not remove value from item""" @@ -535,7 +535,7 @@ class InitializationFromDictTest(unittest.TestCase): self.assertEqual(il.get_output_value("name"), ["foo", "bar"]) loaded_item = il.load_item() self.assertIsInstance(loaded_item, self.item_class) - self.assertEqual(loaded_item, dict({"name": ["foo", "bar"]})) + self.assertEqual(loaded_item, {"name": ["foo", "bar"]}) def test_values_single(self): """Values from initial item must be added to loader._values""" @@ -565,37 +565,37 @@ class NoInputReprocessingFromDictTest(unittest.TestCase): """ def test_avoid_reprocessing_with_initial_values_single(self): - il = NoInputReprocessingDictLoader(item=dict(title="foo")) + il = NoInputReprocessingDictLoader(item={"title": "foo"}) il_loaded = il.load_item() - self.assertEqual(il_loaded, dict(title="foo")) + self.assertEqual(il_loaded, {"title": "foo"}) self.assertEqual( - NoInputReprocessingDictLoader(item=il_loaded).load_item(), dict(title="foo") + NoInputReprocessingDictLoader(item=il_loaded).load_item(), {"title": "foo"} ) def test_avoid_reprocessing_with_initial_values_list(self): - il = NoInputReprocessingDictLoader(item=dict(title=["foo", "bar"])) + il = NoInputReprocessingDictLoader(item={"title": ["foo", "bar"]}) il_loaded = il.load_item() - self.assertEqual(il_loaded, dict(title="foo")) + self.assertEqual(il_loaded, {"title": "foo"}) self.assertEqual( - NoInputReprocessingDictLoader(item=il_loaded).load_item(), dict(title="foo") + NoInputReprocessingDictLoader(item=il_loaded).load_item(), {"title": "foo"} ) def test_avoid_reprocessing_without_initial_values_single(self): il = NoInputReprocessingDictLoader() il.add_value("title", "foo") il_loaded = il.load_item() - self.assertEqual(il_loaded, dict(title="FOO")) + self.assertEqual(il_loaded, {"title": "FOO"}) self.assertEqual( - NoInputReprocessingDictLoader(item=il_loaded).load_item(), dict(title="FOO") + NoInputReprocessingDictLoader(item=il_loaded).load_item(), {"title": "FOO"} ) def test_avoid_reprocessing_without_initial_values_list(self): il = NoInputReprocessingDictLoader() il.add_value("title", ["foo", "bar"]) il_loaded = il.load_item() - self.assertEqual(il_loaded, dict(title="FOO")) + self.assertEqual(il_loaded, {"title": "FOO"}) self.assertEqual( - NoInputReprocessingDictLoader(item=il_loaded).load_item(), dict(title="FOO") + NoInputReprocessingDictLoader(item=il_loaded).load_item(), {"title": "FOO"} ) @@ -678,11 +678,11 @@ class SelectJmesTestCase(unittest.TestCase): } def test_output(self): - for tl in self.test_list_equals: - expr, test_list, expected = self.test_list_equals[tl] + for k, v in self.test_list_equals.items(): + expr, test_list, expected = v test = SelectJmes(expr)(test_list) self.assertEqual( - test, expected, msg=f'test "{tl}" got {test} expected {expected}' + test, expected, msg=f'test "{k}" got {test} expected {expected}' ) diff --git a/tests/test_logstats.py b/tests/test_logstats.py new file mode 100644 index 000000000..d87285df7 --- /dev/null +++ b/tests/test_logstats.py @@ -0,0 +1,62 @@ +import unittest +from datetime import datetime + +from scrapy.extensions.logstats import LogStats +from scrapy.utils.test import get_crawler +from tests.spiders import SimpleSpider + + +class TestLogStats(unittest.TestCase): + def setUp(self): + self.crawler = get_crawler(SimpleSpider) + self.spider = self.crawler._create_spider("spidey") + self.stats = self.crawler.stats + + self.stats.set_value("response_received_count", 4802) + self.stats.set_value("item_scraped_count", 3201) + + def test_stats_calculations(self): + logstats = LogStats.from_crawler(self.crawler) + + with self.assertRaises(AttributeError): + logstats.pagesprev + logstats.itemsprev + + logstats.spider_opened(self.spider) + self.assertEqual(logstats.pagesprev, 4802) + self.assertEqual(logstats.itemsprev, 3201) + + logstats.calculate_stats() + self.assertEqual(logstats.items, 3201) + self.assertEqual(logstats.pages, 4802) + self.assertEqual(logstats.irate, 0.0) + self.assertEqual(logstats.prate, 0.0) + self.assertEqual(logstats.pagesprev, 4802) + self.assertEqual(logstats.itemsprev, 3201) + + # Simulate what happens after a minute + self.stats.set_value("response_received_count", 5187) + self.stats.set_value("item_scraped_count", 3492) + logstats.calculate_stats() + self.assertEqual(logstats.items, 3492) + self.assertEqual(logstats.pages, 5187) + self.assertEqual(logstats.irate, 291.0) + self.assertEqual(logstats.prate, 385.0) + self.assertEqual(logstats.pagesprev, 5187) + self.assertEqual(logstats.itemsprev, 3492) + + # Simulate when spider closes after running for 30 mins + self.stats.set_value("start_time", datetime.fromtimestamp(1655100172)) + self.stats.set_value("finished_time", datetime.fromtimestamp(1655101972)) + logstats.spider_closed(self.spider, "test reason") + self.assertEqual(self.stats.get_value("responses_per_minute"), 172.9) + self.assertEqual(self.stats.get_value("items_per_minute"), 116.4) + + def test_stats_calculations_no_time(self): + """The stat values should be None since the start and finish time are + not available. + """ + logstats = LogStats.from_crawler(self.crawler) + logstats.spider_closed(self.spider, "test reason") + self.assertIsNone(self.stats.get_value("responses_per_minute")) + self.assertIsNone(self.stats.get_value("items_per_minute")) diff --git a/tests/test_mail.py b/tests/test_mail.py index 2535e58db..ff1505397 100644 --- a/tests/test_mail.py +++ b/tests/test_mail.py @@ -91,7 +91,7 @@ class MailSenderTest(unittest.TestCase): self.assertEqual(attach.get_payload(decode=True), b"content") def _catch_mail_sent(self, **kwargs): - self.catched_msg = dict(**kwargs) + self.catched_msg = {**kwargs} def test_send_utf8(self): subject = "sübjèçt" diff --git a/tests/test_pipeline_crawl.py b/tests/test_pipeline_crawl.py index c41ab483f..5cf4a63aa 100644 --- a/tests/test_pipeline_crawl.py +++ b/tests/test_pipeline_crawl.py @@ -1,6 +1,8 @@ +from __future__ import annotations + import shutil from pathlib import Path -from typing import Optional, Set +from tempfile import mkdtemp from testfixtures import LogCapture from twisted.internet import defer @@ -56,7 +58,7 @@ class FileDownloadCrawlTestCase(TestCase): store_setting_key = "FILES_STORE" media_key = "files" media_urls_key = "file_urls" - expected_checksums: Optional[Set[str]] = { + expected_checksums: set[str] | None = { "5547178b89448faf0015a13f904c936e", "c2281c83670e31d8aaab7cb642b824db", "ed3f6538dc15d4d9179dae57319edc5f", @@ -67,8 +69,7 @@ class FileDownloadCrawlTestCase(TestCase): self.mockserver.__enter__() # prepare a directory for storing files - self.tmpmediastore = Path(self.mktemp()) - self.tmpmediastore.mkdir() + self.tmpmediastore = Path(mkdtemp()) self.settings = { "REQUEST_FINGERPRINTER_IMPLEMENTATION": "2.7", "ITEM_PIPELINES": {self.pipeline_class: 1}, @@ -107,9 +108,7 @@ class FileDownloadCrawlTestCase(TestCase): # check that the images/files checksums are what we know they should be if self.expected_checksums is not None: - checksums = set( - i["checksum"] for item in items for i in item[self.media_key] - ) + checksums = {i["checksum"] for item in items for i in item[self.media_key]} self.assertEqual(checksums, self.expected_checksums) # check that the image files where actually written to the media store @@ -140,7 +139,7 @@ class FileDownloadCrawlTestCase(TestCase): self.assertEqual(logs.count(file_dl_failure), 3) # check that no files were written to the media store - self.assertEqual([x for x in self.tmpmediastore.iterdir()], []) + self.assertEqual(list(self.tmpmediastore.iterdir()), []) @defer.inlineCallbacks def test_download_media(self): @@ -218,7 +217,7 @@ class FileDownloadCrawlTestCase(TestCase): self.assertIn("ZeroDivisionError", str(log)) -skip_pillow: Optional[str] +skip_pillow: str | None try: from PIL import Image # noqa: imported just to check for the import error except ImportError: diff --git a/tests/test_pipeline_files.py b/tests/test_pipeline_files.py index e7000e314..6ce7fc059 100644 --- a/tests/test_pipeline_files.py +++ b/tests/test_pipeline_files.py @@ -7,7 +7,6 @@ from io import BytesIO from pathlib import Path from shutil import rmtree from tempfile import mkdtemp -from typing import Dict, List from unittest import mock from urllib.parse import urlparse @@ -221,7 +220,7 @@ class FilesPipelineTestCase(unittest.TestCase): file_path = CustomFilesPipeline.from_settings( Settings({"FILES_STORE": self.tempdir}) ).file_path - item = dict(path="path-to-store-file") + item = {"path": "path-to-store-file"} request = Request("http://example.com") self.assertEqual(file_path(request, item=item), "full/path-to-store-file") @@ -309,11 +308,11 @@ class FilesPipelineTestCaseFieldsDataClass( class FilesPipelineTestAttrsItem: name = attr.ib(default="") # default fields - file_urls: List[str] = attr.ib(default=lambda: []) - files: List[Dict[str, str]] = attr.ib(default=lambda: []) + file_urls: list[str] = attr.ib(default=lambda: []) + files: list[dict[str, str]] = attr.ib(default=lambda: []) # overridden fields - custom_file_urls: List[str] = attr.ib(default=lambda: []) - custom_files: List[Dict[str, str]] = attr.ib(default=lambda: []) + custom_file_urls: list[str] = attr.ib(default=lambda: []) + custom_files: list[dict[str, str]] = attr.ib(default=lambda: []) class FilesPipelineTestCaseFieldsAttrsItem( diff --git a/tests/test_pipeline_images.py b/tests/test_pipeline_images.py index 2e2e06b89..2c3b191fe 100644 --- a/tests/test_pipeline_images.py +++ b/tests/test_pipeline_images.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import dataclasses import hashlib import io @@ -5,7 +7,6 @@ import random import warnings from shutil import rmtree from tempfile import mkdtemp -from typing import Dict, List, Optional from unittest.mock import patch import attr @@ -19,7 +20,7 @@ from scrapy.pipelines.images import ImageException, ImagesPipeline, NoimagesDrop from scrapy.settings import Settings from scrapy.utils.python import to_bytes -skip_pillow: Optional[str] +skip_pillow: str | None try: from PIL import Image except ImportError: @@ -132,7 +133,7 @@ class ImagesPipelineTestCase(unittest.TestCase): thumb_path = CustomImagesPipeline.from_settings( Settings({"IMAGES_STORE": self.tempdir}) ).thumb_path - item = dict(path="path-to-store-file") + item = {"path": "path-to-store-file"} request = Request("http://example.com") self.assertEqual( thumb_path(request, "small", item=item), "thumb/small/path-to-store-file" @@ -406,11 +407,11 @@ class ImagesPipelineTestCaseFieldsDataClass( class ImagesPipelineTestAttrsItem: name = attr.ib(default="") # default fields - image_urls: List[str] = attr.ib(default=lambda: []) - images: List[Dict[str, str]] = attr.ib(default=lambda: []) + image_urls: list[str] = attr.ib(default=lambda: []) + images: list[dict[str, str]] = attr.ib(default=lambda: []) # overridden fields - custom_image_urls: List[str] = attr.ib(default=lambda: []) - custom_images: List[Dict[str, str]] = attr.ib(default=lambda: []) + custom_image_urls: list[str] = attr.ib(default=lambda: []) + custom_images: list[dict[str, str]] = attr.ib(default=lambda: []) class ImagesPipelineTestCaseFieldsAttrsItem( @@ -433,14 +434,14 @@ class ImagesPipelineTestCaseCustomSettings(unittest.TestCase): ] # This should match what is defined in ImagesPipeline. - default_pipeline_settings = dict( - MIN_WIDTH=0, - MIN_HEIGHT=0, - EXPIRES=90, - THUMBS={}, - IMAGES_URLS_FIELD="image_urls", - IMAGES_RESULT_FIELD="images", - ) + default_pipeline_settings = { + "MIN_WIDTH": 0, + "MIN_HEIGHT": 0, + "EXPIRES": 90, + "THUMBS": {}, + "IMAGES_URLS_FIELD": "image_urls", + "IMAGES_RESULT_FIELD": "images", + } def setUp(self): self.tempdir = mkdtemp() @@ -628,7 +629,7 @@ class ImagesPipelineTestCaseCustomSettings(unittest.TestCase): class NoimagesDropTestCase(unittest.TestCase): def test_deprecation_warning(self): - arg = str() + arg = "" with warnings.catch_warnings(record=True) as w: NoimagesDrop(arg) self.assertEqual(len(w), 1) diff --git a/tests/test_pipeline_media.py b/tests/test_pipeline_media.py index 820484565..0faf6d015 100644 --- a/tests/test_pipeline_media.py +++ b/tests/test_pipeline_media.py @@ -1,5 +1,4 @@ -import io -from typing import Optional +from __future__ import annotations from testfixtures import LogCapture from twisted.internet import reactor @@ -11,7 +10,6 @@ from scrapy import signals from scrapy.http import Request, Response from scrapy.http.request import NO_CALLBACK from scrapy.pipelines.files import FileException -from scrapy.pipelines.images import ImagesPipeline from scrapy.pipelines.media import MediaPipeline from scrapy.settings import Settings from scrapy.spiders import Spider @@ -22,9 +20,9 @@ from scrapy.utils.test import get_crawler try: from PIL import Image # noqa: imported just to check for the import error except ImportError: - skip_pillow: Optional[ - str - ] = "Missing Python Imaging Library, install https://pypi.python.org/pypi/Pillow" + skip_pillow: str | None = ( + "Missing Python Imaging Library, install https://pypi.python.org/pypi/Pillow" + ) else: skip_pillow = None @@ -35,8 +33,26 @@ def _mocked_download_func(request, info): return response() if callable(response) else response +class UserDefinedPipeline(MediaPipeline): + + def media_to_download(self, request, info, *, item=None): + pass + + def get_media_requests(self, item, info): + pass + + def media_downloaded(self, response, request, info, *, item=None): + return {} + + def media_failed(self, failure, request, info): + return failure + + def file_path(self, request, response=None, info=None, *, item=None): + return "" + + class BaseMediaPipelineTestCase(unittest.TestCase): - pipeline_class = MediaPipeline + pipeline_class = UserDefinedPipeline settings = None def setUp(self): @@ -54,54 +70,6 @@ class BaseMediaPipelineTestCase(unittest.TestCase): if not name.startswith("_"): disconnect_all(signal) - def test_default_media_to_download(self): - request = Request("http://url") - assert self.pipe.media_to_download(request, self.info) is None - - def test_default_get_media_requests(self): - item = dict(name="name") - assert self.pipe.get_media_requests(item, self.info) is None - - def test_default_media_downloaded(self): - request = Request("http://url") - response = Response("http://url", body=b"") - assert self.pipe.media_downloaded(response, request, self.info) is response - - def test_default_media_failed(self): - request = Request("http://url") - fail = Failure(Exception()) - assert self.pipe.media_failed(fail, request, self.info) is fail - - def test_default_item_completed(self): - item = dict(name="name") - assert self.pipe.item_completed([], item, self.info) is item - - # Check that failures are logged by default - fail = Failure(Exception()) - results = [(True, 1), (False, fail)] - - with LogCapture() as log: - new_item = self.pipe.item_completed(results, item, self.info) - - assert new_item is item - assert len(log.records) == 1 - record = log.records[0] - assert record.levelname == "ERROR" - self.assertTupleEqual(record.exc_info, failure_to_exc_info(fail)) - - # disable failure logging and check again - self.pipe.LOG_FAILED_RESULTS = False - with LogCapture() as log: - new_item = self.pipe.item_completed(results, item, self.info) - assert new_item is item - assert len(log.records) == 0 - - @inlineCallbacks - def test_default_process_item(self): - item = dict(name="name") - new_item = yield self.pipe.process_item(item, self.spider) - assert new_item is item - def test_modify_media_request(self): request = Request("http://url") self.pipe._modify_media_request(request) @@ -175,8 +143,38 @@ class BaseMediaPipelineTestCase(unittest.TestCase): context = getattr(info.downloaded[fp].value, "__context__", None) self.assertIsNone(context) + def test_default_item_completed(self): + item = {"name": "name"} + assert self.pipe.item_completed([], item, self.info) is item -class MockedMediaPipeline(MediaPipeline): + # Check that failures are logged by default + fail = Failure(Exception()) + results = [(True, 1), (False, fail)] + + with LogCapture() as log: + new_item = self.pipe.item_completed(results, item, self.info) + + assert new_item is item + assert len(log.records) == 1 + record = log.records[0] + assert record.levelname == "ERROR" + self.assertTupleEqual(record.exc_info, failure_to_exc_info(fail)) + + # disable failure logging and check again + self.pipe.LOG_FAILED_RESULTS = False + with LogCapture() as log: + new_item = self.pipe.item_completed(results, item, self.info) + assert new_item is item + assert len(log.records) == 0 + + @inlineCallbacks + def test_default_process_item(self): + item = {"name": "name"} + new_item = yield self.pipe.process_item(item, self.spider) + assert new_item is item + + +class MockedMediaPipeline(UserDefinedPipeline): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._mockcalled = [] @@ -213,10 +211,6 @@ class MockedMediaPipeline(MediaPipeline): class MediaPipelineTestCase(BaseMediaPipelineTestCase): pipeline_class = MockedMediaPipeline - def _callback(self, result): - self.pipe._mockcalled.append("request_callback") - return result - def _errback(self, result): self.pipe._mockcalled.append("request_errback") return result @@ -226,20 +220,18 @@ class MediaPipelineTestCase(BaseMediaPipelineTestCase): rsp = Response("http://url1") req = Request( "http://url1", - meta=dict(response=rsp), - callback=self._callback, + meta={"response": rsp}, errback=self._errback, ) - item = dict(requests=req) + item = {"requests": req} new_item = yield self.pipe.process_item(item, self.spider) - self.assertEqual(new_item["results"], [(True, rsp)]) + self.assertEqual(new_item["results"], [(True, {})]) self.assertEqual( self.pipe._mockcalled, [ "get_media_requests", "media_to_download", "media_downloaded", - "request_callback", "item_completed", ], ) @@ -250,11 +242,10 @@ class MediaPipelineTestCase(BaseMediaPipelineTestCase): fail = Failure(Exception()) req = Request( "http://url1", - meta=dict(response=fail), - callback=self._callback, + meta={"response": fail}, errback=self._errback, ) - item = dict(requests=req) + item = {"requests": req} new_item = yield self.pipe.process_item(item, self.spider) self.assertEqual(new_item["results"], [(False, fail)]) self.assertEqual( @@ -272,12 +263,12 @@ class MediaPipelineTestCase(BaseMediaPipelineTestCase): def test_mix_of_success_and_failure(self): self.pipe.LOG_FAILED_RESULTS = False rsp1 = Response("http://url1") - req1 = Request("http://url1", meta=dict(response=rsp1)) + req1 = Request("http://url1", meta={"response": rsp1}) fail = Failure(Exception()) - req2 = Request("http://url2", meta=dict(response=fail)) - item = dict(requests=[req1, req2]) + req2 = Request("http://url2", meta={"response": fail}) + item = {"requests": [req1, req2]} new_item = yield self.pipe.process_item(item, self.spider) - self.assertEqual(new_item["results"], [(True, rsp1), (False, fail)]) + self.assertEqual(new_item["results"], [(True, {}), (False, fail)]) m = self.pipe._mockcalled # only once self.assertEqual(m[0], "get_media_requests") # first hook called @@ -294,7 +285,7 @@ class MediaPipelineTestCase(BaseMediaPipelineTestCase): def test_get_media_requests(self): # returns single Request (without callback) req = Request("http://url") - item = dict(requests=req) # pass a single item + item = {"requests": req} # pass a single item new_item = yield self.pipe.process_item(item, self.spider) assert new_item is item self.assertIn(self.fingerprint(req), self.info.downloaded) @@ -302,7 +293,7 @@ class MediaPipelineTestCase(BaseMediaPipelineTestCase): # returns iterable of Requests req1 = Request("http://url1") req2 = Request("http://url2") - item = dict(requests=iter([req1, req2])) + item = {"requests": iter([req1, req2])} new_item = yield self.pipe.process_item(item, self.spider) assert new_item is item assert self.fingerprint(req1) in self.info.downloaded @@ -311,33 +302,33 @@ class MediaPipelineTestCase(BaseMediaPipelineTestCase): @inlineCallbacks def test_results_are_cached_across_multiple_items(self): rsp1 = Response("http://url1") - req1 = Request("http://url1", meta=dict(response=rsp1)) - item = dict(requests=req1) + req1 = Request("http://url1", meta={"response": rsp1}) + item = {"requests": req1} new_item = yield self.pipe.process_item(item, self.spider) self.assertTrue(new_item is item) - self.assertEqual(new_item["results"], [(True, rsp1)]) + self.assertEqual(new_item["results"], [(True, {})]) # rsp2 is ignored, rsp1 must be in results because request fingerprints are the same req2 = Request( - req1.url, meta=dict(response=Response("http://donot.download.me")) + req1.url, meta={"response": Response("http://donot.download.me")} ) - item = dict(requests=req2) + item = {"requests": req2} new_item = yield self.pipe.process_item(item, self.spider) self.assertTrue(new_item is item) self.assertEqual(self.fingerprint(req1), self.fingerprint(req2)) - self.assertEqual(new_item["results"], [(True, rsp1)]) + self.assertEqual(new_item["results"], [(True, {})]) @inlineCallbacks def test_results_are_cached_for_requests_of_single_item(self): rsp1 = Response("http://url1") - req1 = Request("http://url1", meta=dict(response=rsp1)) + req1 = Request("http://url1", meta={"response": rsp1}) req2 = Request( - req1.url, meta=dict(response=Response("http://donot.download.me")) + req1.url, meta={"response": Response("http://donot.download.me")} ) - item = dict(requests=[req1, req2]) + item = {"requests": [req1, req2]} new_item = yield self.pipe.process_item(item, self.spider) self.assertTrue(new_item is item) - self.assertEqual(new_item["results"], [(True, rsp1), (True, rsp1)]) + self.assertEqual(new_item["results"], [(True, {}), (True, {})]) @inlineCallbacks def test_wait_if_request_is_downloading(self): @@ -359,16 +350,16 @@ class MediaPipelineTestCase(BaseMediaPipelineTestCase): def rsp2_func(): self.fail("it must cache rsp1 result and must not try to redownload") - req1 = Request("http://url", meta=dict(response=rsp1_func)) - req2 = Request(req1.url, meta=dict(response=rsp2_func)) - item = dict(requests=[req1, req2]) + req1 = Request("http://url", meta={"response": rsp1_func}) + req2 = Request(req1.url, meta={"response": rsp2_func}) + item = {"requests": [req1, req2]} new_item = yield self.pipe.process_item(item, self.spider) - self.assertEqual(new_item["results"], [(True, rsp1), (True, rsp1)]) + self.assertEqual(new_item["results"], [(True, {}), (True, {})]) @inlineCallbacks def test_use_media_to_download_result(self): - req = Request("http://url", meta=dict(result="ITSME", response=self.fail)) - item = dict(requests=req) + req = Request("http://url", meta={"result": "ITSME", "response": self.fail}) + item = {"requests": req} new_item = yield self.pipe.process_item(item, self.spider) self.assertEqual(new_item["results"], [(True, "ITSME")]) self.assertEqual( @@ -376,57 +367,15 @@ class MediaPipelineTestCase(BaseMediaPipelineTestCase): ["get_media_requests", "media_to_download", "item_completed"], ) - -class MockedMediaPipelineDeprecatedMethods(ImagesPipeline): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._mockcalled = [] - - def get_media_requests(self, item, info): - item_url = item["image_urls"][0] - output_img = io.BytesIO() - img = Image.new("RGB", (60, 30), color="red") - img.save(output_img, format="JPEG") - return Request( - item_url, - meta={ - "response": Response(item_url, status=200, body=output_img.getvalue()) - }, + def test_key_for_pipe(self): + self.assertEqual( + self.pipe._key_for_pipe("IMAGES", base_class_name="MediaPipeline"), + "MOCKEDMEDIAPIPELINE_IMAGES", ) - def inc_stats(self, *args, **kwargs): - return True - - def media_to_download(self, request, info): - self._mockcalled.append("media_to_download") - return super().media_to_download(request, info) - - def media_downloaded(self, response, request, info): - self._mockcalled.append("media_downloaded") - return super().media_downloaded(response, request, info) - - def file_downloaded(self, response, request, info): - self._mockcalled.append("file_downloaded") - return super().file_downloaded(response, request, info) - - def file_path(self, request, response=None, info=None): - self._mockcalled.append("file_path") - return super().file_path(request, response, info) - - def thumb_path(self, request, thumb_id, response=None, info=None): - self._mockcalled.append("thumb_path") - return super().thumb_path(request, thumb_id, response, info) - - def get_images(self, response, request, info): - self._mockcalled.append("get_images") - return super().get_images(response, request, info) - - def image_downloaded(self, response, request, info): - self._mockcalled.append("image_downloaded") - return super().image_downloaded(response, request, info) - class MediaPipelineAllowRedirectSettingsTestCase(unittest.TestCase): + def _assert_request_no3xx(self, pipeline_class, settings): pipe = pipeline_class(settings=Settings(settings)) request = Request("http://url") @@ -452,18 +401,11 @@ class MediaPipelineAllowRedirectSettingsTestCase(unittest.TestCase): else: self.assertNotIn(status, request.meta["handle_httpstatus_list"]) - def test_standard_setting(self): - self._assert_request_no3xx(MediaPipeline, {"MEDIA_ALLOW_REDIRECTS": True}) - def test_subclass_standard_setting(self): - class UserDefinedPipeline(MediaPipeline): - pass self._assert_request_no3xx(UserDefinedPipeline, {"MEDIA_ALLOW_REDIRECTS": True}) def test_subclass_specific_setting(self): - class UserDefinedPipeline(MediaPipeline): - pass self._assert_request_no3xx( UserDefinedPipeline, {"USERDEFINEDPIPELINE_MEDIA_ALLOW_REDIRECTS": True} diff --git a/tests/test_proxy_connect.py b/tests/test_proxy_connect.py index 46d42e9f6..93f006c76 100644 --- a/tests/test_proxy_connect.py +++ b/tests/test_proxy_connect.py @@ -27,7 +27,7 @@ from mitmproxy.tools.main import mitmdump sys.argv[0] = "mitmdump" sys.exit(mitmdump()) """ - cert_path = Path(__file__).parent.resolve() / "keys" / "mitmproxy-ca.pem" + cert_path = Path(__file__).parent.resolve() / "keys" self.proc = Popen( [ sys.executable, @@ -40,8 +40,8 @@ sys.exit(mitmdump()) "0", "--proxyauth", f"{self.auth_user}:{self.auth_pass}", - "--certs", - str(cert_path), + "--set", + f"confdir={cert_path}", "--ssl-insecure", ], stdout=PIPE, diff --git a/tests/test_request_cb_kwargs.py b/tests/test_request_cb_kwargs.py index 7299972f6..8c0e5764a 100644 --- a/tests/test_request_cb_kwargs.py +++ b/tests/test_request_cb_kwargs.py @@ -1,5 +1,3 @@ -from typing import List - from testfixtures import LogCapture from twisted.internet import defer from twisted.trial.unittest import TestCase @@ -64,7 +62,7 @@ class KeywordArgumentsSpider(MockServerSpider): }, } - checks: List[bool] = [] + checks: list[bool] = [] def start_requests(self): data = {"key": "value", "number": 123, "callback": "some_callback"} diff --git a/tests/test_request_dict.py b/tests/test_request_dict.py index 7312eb036..d3f416347 100644 --- a/tests/test_request_dict.py +++ b/tests/test_request_dict.py @@ -147,7 +147,7 @@ class RequestSerializationTest(unittest.TestCase): spider = MySpider() r = Request("http://www.example.com", callback=spider.parse) - setattr(spider, "parse", None) + spider.parse = None self.assertRaises(ValueError, r.to_dict, spider=spider) def test_callback_not_available(self): diff --git a/tests/test_responsetypes.py b/tests/test_responsetypes.py index 713a83d52..7be8150fc 100644 --- a/tests/test_responsetypes.py +++ b/tests/test_responsetypes.py @@ -29,11 +29,14 @@ class ResponseTypesTest(unittest.TestCase): mappings = [ (b'attachment; filename="data.xml"', XmlResponse), (b"attachment; filename=data.xml", XmlResponse), - ("attachment;filename=data£.tar.gz".encode("utf-8"), Response), + ("attachment;filename=data£.tar.gz".encode(), Response), ("attachment;filename=dataµ.tar.gz".encode("latin-1"), Response), ("attachment;filename=data高.doc".encode("gbk"), Response), ("attachment;filename=دورهdata.html".encode("cp720"), HtmlResponse), - ("attachment;filename=日本語版Wikipedia.xml".encode("iso2022_jp"), XmlResponse), + ( + "attachment;filename=日本語版Wikipedia.xml".encode("iso2022_jp"), + XmlResponse, + ), ] for source, cls in mappings: retcls = responsetypes.from_content_disposition(source) diff --git a/tests/test_robotstxt_interface.py b/tests/test_robotstxt_interface.py index d7a923085..541979dcc 100644 --- a/tests/test_robotstxt_interface.py +++ b/tests/test_robotstxt_interface.py @@ -1,13 +1,6 @@ from twisted.trial import unittest - -def reppy_available(): - # check if reppy parser is installed - try: - from reppy.robots import Robots # noqa: F401 - except ImportError: - return False - return True +from scrapy.robotstxt import decode_robotstxt def rerp_available(): @@ -34,10 +27,10 @@ class BaseRobotParserTest: def test_allowed(self): robotstxt_robotstxt_body = ( - "User-agent: * \n" - "Disallow: /disallowed \n" - "Allow: /allowed \n" - "Crawl-delay: 10".encode("utf-8") + b"User-agent: * \n" + b"Disallow: /disallowed \n" + b"Allow: /allowed \n" + b"Crawl-delay: 10" ) rp = self.parser_cls.from_crawler( crawler=None, robotstxt_body=robotstxt_robotstxt_body @@ -46,15 +39,13 @@ class BaseRobotParserTest: self.assertFalse(rp.allowed("https://www.site.local/disallowed", "*")) def test_allowed_wildcards(self): - robotstxt_robotstxt_body = """User-agent: first + robotstxt_robotstxt_body = b"""User-agent: first Disallow: /disallowed/*/end$ User-agent: second Allow: /*allowed Disallow: / - """.encode( - "utf-8" - ) + """ rp = self.parser_cls.from_crawler( crawler=None, robotstxt_body=robotstxt_robotstxt_body ) @@ -75,18 +66,14 @@ class BaseRobotParserTest: self.assertTrue(rp.allowed("https://www.site.local/is_allowed_too", "second")) def test_length_based_precedence(self): - robotstxt_robotstxt_body = ( - "User-agent: * \n" "Disallow: / \n" "Allow: /page".encode("utf-8") - ) + robotstxt_robotstxt_body = b"User-agent: * \n" b"Disallow: / \n" b"Allow: /page" rp = self.parser_cls.from_crawler( crawler=None, robotstxt_body=robotstxt_robotstxt_body ) self.assertTrue(rp.allowed("https://www.site.local/page", "*")) def test_order_based_precedence(self): - robotstxt_robotstxt_body = ( - "User-agent: * \n" "Disallow: / \n" "Allow: /page".encode("utf-8") - ) + robotstxt_robotstxt_body = b"User-agent: * \n" b"Disallow: / \n" b"Allow: /page" rp = self.parser_cls.from_crawler( crawler=None, robotstxt_body=robotstxt_robotstxt_body ) @@ -121,9 +108,7 @@ class BaseRobotParserTest: Disallow: /wiki/Käyttäjä: User-Agent: UnicödeBöt - Disallow: /some/randome/page.html""".encode( - "utf-8" - ) + Disallow: /some/randome/page.html""".encode() rp = self.parser_cls.from_crawler( crawler=None, robotstxt_body=robotstxt_robotstxt_body ) @@ -141,6 +126,25 @@ class BaseRobotParserTest: ) +class DecodeRobotsTxtTest(unittest.TestCase): + def test_native_string_conversion(self): + robotstxt_body = b"User-agent: *\nDisallow: /\n" + decoded_content = decode_robotstxt( + robotstxt_body, spider=None, to_native_str_type=True + ) + self.assertEqual(decoded_content, "User-agent: *\nDisallow: /\n") + + def test_decode_utf8(self): + robotstxt_body = b"User-agent: *\nDisallow: /\n" + decoded_content = decode_robotstxt(robotstxt_body, spider=None) + self.assertEqual(decoded_content, "User-agent: *\nDisallow: /\n") + + def test_decode_non_utf8(self): + robotstxt_body = b"User-agent: *\n\xFFDisallow: /\n" + decoded_content = decode_robotstxt(robotstxt_body, spider=None) + self.assertEqual(decoded_content, "User-agent: *\nDisallow: /\n") + + class PythonRobotParserTest(BaseRobotParserTest, unittest.TestCase): def setUp(self): from scrapy.robotstxt import PythonRobotParser @@ -156,21 +160,6 @@ class PythonRobotParserTest(BaseRobotParserTest, unittest.TestCase): raise unittest.SkipTest("RobotFileParser does not support wildcards.") -class ReppyRobotParserTest(BaseRobotParserTest, unittest.TestCase): - if not reppy_available(): - skip = "Reppy parser is not installed" - - def setUp(self): - from scrapy.robotstxt import ReppyRobotParser - - super()._setUp(ReppyRobotParser) - - def test_order_based_precedence(self): - raise unittest.SkipTest( - "Reppy does not support order based directives precedence." - ) - - class RerpRobotParserTest(BaseRobotParserTest, unittest.TestCase): if not rerp_available(): skip = "Rerp parser is not installed" diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index f8465a5ff..6b7cd5dac 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -1,8 +1,9 @@ +from __future__ import annotations + import collections import shutil import tempfile import unittest -from typing import Optional from twisted.internet import defer from twisted.trial.unittest import TestCase @@ -25,7 +26,7 @@ class MockDownloader: def __init__(self): self.slots = {} - def _get_slot_key(self, request, spider): + def get_slot_key(self, request): if Downloader.DOWNLOAD_SLOT in request.meta: return request.meta[Downloader.DOWNLOAD_SLOT] @@ -45,22 +46,22 @@ class MockDownloader: class MockCrawler(Crawler): def __init__(self, priority_queue_cls, jobdir): - settings = dict( - SCHEDULER_DEBUG=False, - SCHEDULER_DISK_QUEUE="scrapy.squeues.PickleLifoDiskQueue", - SCHEDULER_MEMORY_QUEUE="scrapy.squeues.LifoMemoryQueue", - SCHEDULER_PRIORITY_QUEUE=priority_queue_cls, - JOBDIR=jobdir, - DUPEFILTER_CLASS="scrapy.dupefilters.BaseDupeFilter", - REQUEST_FINGERPRINTER_IMPLEMENTATION="2.7", - ) + settings = { + "SCHEDULER_DEBUG": False, + "SCHEDULER_DISK_QUEUE": "scrapy.squeues.PickleLifoDiskQueue", + "SCHEDULER_MEMORY_QUEUE": "scrapy.squeues.LifoMemoryQueue", + "SCHEDULER_PRIORITY_QUEUE": priority_queue_cls, + "JOBDIR": jobdir, + "DUPEFILTER_CLASS": "scrapy.dupefilters.BaseDupeFilter", + "REQUEST_FINGERPRINTER_IMPLEMENTATION": "2.7", + } super().__init__(Spider, settings) self.engine = MockEngine(downloader=MockDownloader()) self.stats = load_object(self.settings["STATS_CLASS"])(self) class SchedulerHandler: - priority_queue_cls: Optional[str] = None + priority_queue_cls: str | None = None jobdir = None def create_scheduler(self): @@ -254,7 +255,7 @@ def _is_scheduling_fair(enqueued_slots, dequeued_slots): class DownloaderAwareSchedulerTestMixin: - priority_queue_cls: Optional[str] = "scrapy.pqueues.DownloaderAwarePriorityQueue" + priority_queue_cls: str | None = "scrapy.pqueues.DownloaderAwarePriorityQueue" reopen = False def test_logic(self): @@ -273,18 +274,18 @@ class DownloaderAwareSchedulerTestMixin: while self.scheduler.has_pending_requests(): request = self.scheduler.next_request() # pylint: disable=protected-access - slot = downloader._get_slot_key(request, None) + slot = downloader.get_slot_key(request) dequeued_slots.append(slot) downloader.increment(slot) requests.append(request) for request in requests: # pylint: disable=protected-access - slot = downloader._get_slot_key(request, None) + slot = downloader.get_slot_key(request) downloader.decrement(slot) self.assertTrue( - _is_scheduling_fair(list(s for u, s in _URLS_WITH_SLOTS), dequeued_slots) + _is_scheduling_fair([s for u, s in _URLS_WITH_SLOTS], dequeued_slots) ) self.assertEqual(sum(len(s.active) for s in downloader.slots.values()), 0) @@ -338,10 +339,10 @@ class TestIntegrationWithDownloaderAwareInMemory(TestCase): class TestIncompatibility(unittest.TestCase): def _incompatible(self): - settings = dict( - SCHEDULER_PRIORITY_QUEUE="scrapy.pqueues.DownloaderAwarePriorityQueue", - CONCURRENT_REQUESTS_PER_IP=1, - ) + settings = { + "SCHEDULER_PRIORITY_QUEUE": "scrapy.pqueues.DownloaderAwarePriorityQueue", + "CONCURRENT_REQUESTS_PER_IP": 1, + } crawler = get_crawler(Spider, settings) scheduler = Scheduler.from_crawler(crawler) spider = Spider(name="spider") diff --git a/tests/test_scheduler_base.py b/tests/test_scheduler_base.py index 76ca777a8..b48a65e67 100644 --- a/tests/test_scheduler_base.py +++ b/tests/test_scheduler_base.py @@ -1,6 +1,7 @@ -from typing import Dict, Optional +from __future__ import annotations + from unittest import TestCase -from urllib.parse import urljoin, urlparse +from urllib.parse import urljoin from testfixtures import LogCapture from twisted.internet import defer @@ -9,6 +10,7 @@ from twisted.trial.unittest import TestCase as TwistedTestCase from scrapy.core.scheduler import BaseScheduler from scrapy.http import Request from scrapy.spiders import Spider +from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.request import fingerprint from scrapy.utils.test import get_crawler from tests.mockserver import MockServer @@ -19,7 +21,7 @@ URLS = [urljoin("https://example.org", p) for p in PATHS] class MinimalScheduler: def __init__(self) -> None: - self.requests: Dict[bytes, Request] = {} + self.requests: dict[bytes, Request] = {} def has_pending_requests(self) -> bool: return bool(self.requests) @@ -31,7 +33,7 @@ class MinimalScheduler: return True return False - def next_request(self) -> Optional[Request]: + def next_request(self) -> Request | None: if self.has_pending_requests(): fp, request = self.requests.popitem() return request @@ -57,7 +59,7 @@ class TestSpider(Spider): self.start_urls = map(mockserver.url, PATHS) def parse(self, response): - return {"path": urlparse(response.url).path} + return {"path": urlparse_cached(response).path} class InterfaceCheckMixin: diff --git a/tests/test_selector.py b/tests/test_selector.py index 85527bba9..1b5f3f018 100644 --- a/tests/test_selector.py +++ b/tests/test_selector.py @@ -80,10 +80,10 @@ class SelectorTestCase(unittest.TestCase): meta = ( '<meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1">' ) - head = "<head>" + meta + "</head>" + head = f"<head>{meta}</head>" body_content = '<span id="blank">\xa3</span>' - body = "<body>" + body_content + "</body>" - html = "<html>" + head + body + "</html>" + body = f"<body>{body_content}</body>" + html = f"<html>{head}{body}</html>" encoding = "utf-8" html_utf8 = html.encode(encoding) diff --git a/tests/test_settings/__init__.py b/tests/test_settings/__init__.py index 3fde5e8c5..503c29e32 100644 --- a/tests/test_settings/__init__.py +++ b/tests/test_settings/__init__.py @@ -105,9 +105,10 @@ class BaseSettingsTest(unittest.TestCase): def test_set_calls_settings_attributes_methods_on_update(self): attr = SettingsAttribute("value", 10) - with mock.patch.object(attr, "__setattr__") as mock_setattr, mock.patch.object( - attr, "set" - ) as mock_set: + with ( + mock.patch.object(attr, "__setattr__") as mock_setattr, + mock.patch.object(attr, "set") as mock_set, + ): self.settings.attributes = {"TEST_OPTION": attr} for priority in (0, 10, 20): @@ -426,7 +427,7 @@ class SettingsTest(unittest.TestCase): mydict = settings.get("TEST_DICT") self.assertIsInstance(mydict, BaseSettings) self.assertIn("key", mydict) - self.assertEqual(mydict["key"], "val") + self.assertEqual(mydict["key"], "val") # pylint: disable=unsubscriptable-object self.assertEqual(mydict.getpriority("key"), 0) @mock.patch("scrapy.settings.default_settings", default_settings) diff --git a/tests/test_spider.py b/tests/test_spider.py index 9ce40f921..18a863350 100644 --- a/tests/test_spider.py +++ b/tests/test_spider.py @@ -2,6 +2,8 @@ import gzip import inspect import warnings from io import BytesIO +from logging import WARNING +from pathlib import Path from typing import Any from unittest import mock @@ -25,7 +27,7 @@ from scrapy.spiders import ( ) from scrapy.spiders.init import InitSpider from scrapy.utils.test import get_crawler -from tests import get_testdata +from tests import get_testdata, tests_datadir class SpiderTest(unittest.TestCase): @@ -149,10 +151,10 @@ class XMLFeedSpiderTest(SpiderTest): body = b"""<?xml version="1.0" encoding="UTF-8"?> <urlset xmlns:x="http://www.google.com/schemas/sitemap/0.84" xmlns:y="http://www.example.com/schemas/extras/1.0"> - <url><x:loc>http://www.example.com/Special-Offers.html</loc><y:updated>2009-08-16</updated> + <url><x:loc>http://www.example.com/Special-Offers.html</x:loc><y:updated>2009-08-16</y:updated> <other value="bar" y:custom="fuu"/> </url> - <url><loc>http://www.example.com/</loc><y:updated>2009-08-16</updated><other value="foo"/></url> + <url><loc>http://www.example.com/</loc><y:updated>2009-08-16</y:updated><other value="foo"/></url> </urlset>""" response = XmlResponse(url="http://example.com/sitemap.xml", body=body) @@ -242,7 +244,7 @@ class CrawlSpiderTest(SpiderTest): spider = _CrawlSpider() output = list(spider._requests_to_follow(response)) self.assertEqual(len(output), 3) - self.assertTrue(all(map(lambda r: isinstance(r, Request), output))) + self.assertTrue(all(isinstance(r, Request) for r in output)) self.assertEqual( [r.url for r in output], [ @@ -268,7 +270,7 @@ class CrawlSpiderTest(SpiderTest): spider = _CrawlSpider() output = list(spider._requests_to_follow(response)) self.assertEqual(len(output), 3) - self.assertTrue(all(map(lambda r: isinstance(r, Request), output))) + self.assertTrue(all(isinstance(r, Request) for r in output)) self.assertEqual( [r.url for r in output], [ @@ -297,7 +299,7 @@ class CrawlSpiderTest(SpiderTest): spider = _CrawlSpider() output = list(spider._requests_to_follow(response)) self.assertEqual(len(output), 2) - self.assertTrue(all(map(lambda r: isinstance(r, Request), output))) + self.assertTrue(all(isinstance(r, Request) for r in output)) self.assertEqual( [r.url for r in output], [ @@ -322,7 +324,7 @@ class CrawlSpiderTest(SpiderTest): spider = _CrawlSpider() output = list(spider._requests_to_follow(response)) self.assertEqual(len(output), 3) - self.assertTrue(all(map(lambda r: isinstance(r, Request), output))) + self.assertTrue(all(isinstance(r, Request) for r in output)) self.assertEqual( [r.url for r in output], [ @@ -350,7 +352,7 @@ class CrawlSpiderTest(SpiderTest): spider = _CrawlSpider() output = list(spider._requests_to_follow(response)) self.assertEqual(len(output), 3) - self.assertTrue(all(map(lambda r: isinstance(r, Request), output))) + self.assertTrue(all(isinstance(r, Request) for r in output)) self.assertEqual( [r.url for r in output], [ @@ -381,7 +383,7 @@ class CrawlSpiderTest(SpiderTest): spider = _CrawlSpider() output = list(spider._requests_to_follow(response)) self.assertEqual(len(output), 3) - self.assertTrue(all(map(lambda r: isinstance(r, Request), output))) + self.assertTrue(all(isinstance(r, Request) for r in output)) self.assertEqual( [r.url for r in output], [ @@ -411,7 +413,7 @@ class CrawlSpiderTest(SpiderTest): spider = _CrawlSpider() output = list(spider._requests_to_follow(response)) self.assertEqual(len(output), 3) - self.assertTrue(all(map(lambda r: isinstance(r, Request), output))) + self.assertTrue(all(isinstance(r, Request) for r in output)) self.assertEqual( [r.url for r in output], [ @@ -443,7 +445,7 @@ class CrawlSpiderTest(SpiderTest): spider = _CrawlSpider() output = list(spider._requests_to_follow(response)) self.assertEqual(len(output), 3) - self.assertTrue(all(map(lambda r: isinstance(r, Request), output))) + self.assertTrue(all(isinstance(r, Request) for r in output)) self.assertEqual( [r.url for r in output], [ @@ -488,7 +490,8 @@ class SitemapSpiderTest(SpiderTest): GZBODY = f.getvalue() def assertSitemapBody(self, response, body): - spider = self.spider_class("example.com") + crawler = get_crawler() + spider = self.spider_class.from_crawler(crawler, "example.com") self.assertEqual(spider._get_sitemap_body(response), body) def test_get_sitemap_body(self): @@ -506,6 +509,7 @@ class SitemapSpiderTest(SpiderTest): url="http://www.example.com/sitemap", body=self.GZBODY, headers={"content-type": "application/gzip"}, + request=Request("http://www.example.com/sitemap"), ) self.assertSitemapBody(r, self.BODY) @@ -514,7 +518,11 @@ class SitemapSpiderTest(SpiderTest): self.assertSitemapBody(r, self.BODY) def test_get_sitemap_body_xml_url_compressed(self): - r = Response(url="http://www.example.com/sitemap.xml.gz", body=self.GZBODY) + r = Response( + url="http://www.example.com/sitemap.xml.gz", + body=self.GZBODY, + request=Request("http://www.example.com/sitemap"), + ) self.assertSitemapBody(r, self.BODY) # .xml.gz but body decoded by HttpCompression middleware already @@ -629,7 +637,7 @@ Sitemap: /sitemap-relative-url.xml class FilteredSitemapSpider(self.spider_class): def sitemap_filter(self, entries): for entry in entries: - alternate_links = entry.get("alternate", tuple()) + alternate_links = entry.get("alternate", ()) for link in alternate_links: if "/deutsch/" in link: entry["loc"] = link @@ -691,6 +699,116 @@ Sitemap: /sitemap-relative-url.xml ["http://www.example.com/sitemap2.xml"], ) + def test_compression_bomb_setting(self): + settings = {"DOWNLOAD_MAXSIZE": 10_000_000} + crawler = get_crawler(settings_dict=settings) + spider = self.spider_class.from_crawler(crawler, "example.com") + body_path = Path(tests_datadir, "compressed", "bomb-gzip.bin") + body = body_path.read_bytes() + request = Request(url="https://example.com") + response = Response(url="https://example.com", body=body, request=request) + self.assertIsNone(spider._get_sitemap_body(response)) + + def test_compression_bomb_spider_attr(self): + class DownloadMaxSizeSpider(self.spider_class): + download_maxsize = 10_000_000 + + crawler = get_crawler() + spider = DownloadMaxSizeSpider.from_crawler(crawler, "example.com") + body_path = Path(tests_datadir, "compressed", "bomb-gzip.bin") + body = body_path.read_bytes() + request = Request(url="https://example.com") + response = Response(url="https://example.com", body=body, request=request) + self.assertIsNone(spider._get_sitemap_body(response)) + + def test_compression_bomb_request_meta(self): + crawler = get_crawler() + spider = self.spider_class.from_crawler(crawler, "example.com") + body_path = Path(tests_datadir, "compressed", "bomb-gzip.bin") + body = body_path.read_bytes() + request = Request( + url="https://example.com", meta={"download_maxsize": 10_000_000} + ) + response = Response(url="https://example.com", body=body, request=request) + self.assertIsNone(spider._get_sitemap_body(response)) + + def test_download_warnsize_setting(self): + settings = {"DOWNLOAD_WARNSIZE": 10_000_000} + crawler = get_crawler(settings_dict=settings) + spider = self.spider_class.from_crawler(crawler, "example.com") + body_path = Path(tests_datadir, "compressed", "bomb-gzip.bin") + body = body_path.read_bytes() + request = Request(url="https://example.com") + response = Response(url="https://example.com", body=body, request=request) + with LogCapture( + "scrapy.spiders.sitemap", propagate=False, level=WARNING + ) as log: + spider._get_sitemap_body(response) + log.check( + ( + "scrapy.spiders.sitemap", + "WARNING", + ( + "<200 https://example.com> body size after decompression " + "(11511612 B) is larger than the download warning size " + "(10000000 B)." + ), + ), + ) + + def test_download_warnsize_spider_attr(self): + class DownloadWarnSizeSpider(self.spider_class): + download_warnsize = 10_000_000 + + crawler = get_crawler() + spider = DownloadWarnSizeSpider.from_crawler(crawler, "example.com") + body_path = Path(tests_datadir, "compressed", "bomb-gzip.bin") + body = body_path.read_bytes() + request = Request( + url="https://example.com", meta={"download_warnsize": 10_000_000} + ) + response = Response(url="https://example.com", body=body, request=request) + with LogCapture( + "scrapy.spiders.sitemap", propagate=False, level=WARNING + ) as log: + spider._get_sitemap_body(response) + log.check( + ( + "scrapy.spiders.sitemap", + "WARNING", + ( + "<200 https://example.com> body size after decompression " + "(11511612 B) is larger than the download warning size " + "(10000000 B)." + ), + ), + ) + + def test_download_warnsize_request_meta(self): + crawler = get_crawler() + spider = self.spider_class.from_crawler(crawler, "example.com") + body_path = Path(tests_datadir, "compressed", "bomb-gzip.bin") + body = body_path.read_bytes() + request = Request( + url="https://example.com", meta={"download_warnsize": 10_000_000} + ) + response = Response(url="https://example.com", body=body, request=request) + with LogCapture( + "scrapy.spiders.sitemap", propagate=False, level=WARNING + ) as log: + spider._get_sitemap_body(response) + log.check( + ( + "scrapy.spiders.sitemap", + "WARNING", + ( + "<200 https://example.com> body size after decompression " + "(11511612 B) is larger than the download warning size " + "(10000000 B)." + ), + ), + ) + class DeprecationTest(unittest.TestCase): def test_crawl_spider(self): diff --git a/tests/test_spiderloader/__init__.py b/tests/test_spiderloader/__init__.py index 04025d30d..32699d837 100644 --- a/tests/test_spiderloader/__init__.py +++ b/tests/test_spiderloader/__init__.py @@ -3,6 +3,8 @@ import sys import tempfile import warnings from pathlib import Path +from tempfile import mkdtemp +from unittest import mock from twisted.trial import unittest from zope.interface.verify import verifyObject @@ -135,12 +137,38 @@ class SpiderLoaderTest(unittest.TestCase): spiders = spider_loader.list() self.assertEqual(spiders, []) + def test_syntax_error_exception(self): + module = "tests.test_spiderloader.test_spiders.spider1" + with mock.patch.object(SpiderLoader, "_load_spiders") as m: + m.side_effect = SyntaxError + settings = Settings({"SPIDER_MODULES": [module]}) + self.assertRaises(SyntaxError, SpiderLoader.from_settings, settings) + + def test_syntax_error_warning(self): + with warnings.catch_warnings(record=True) as w, mock.patch.object( + SpiderLoader, "_load_spiders" + ) as m: + m.side_effect = SyntaxError + module = "tests.test_spiderloader.test_spiders.spider1" + settings = Settings( + {"SPIDER_MODULES": [module], "SPIDER_LOADER_WARN_ONLY": True} + ) + spider_loader = SpiderLoader.from_settings(settings) + if str(w[0].message).startswith("_SixMetaPathImporter"): + # needed on 3.10 because of https://github.com/benjaminp/six/issues/349, + # at least until all six versions we can import (including botocore.vendored.six) + # are updated to 1.16.0+ + w.pop(0) + self.assertIn("Could not load spiders from module", str(w[0].message)) + + spiders = spider_loader.list() + self.assertEqual(spiders, []) + class DuplicateSpiderNameLoaderTest(unittest.TestCase): def setUp(self): orig_spiders_dir = module_dir / "test_spiders" - self.tmpdir = Path(self.mktemp()) - self.tmpdir.mkdir() + self.tmpdir = Path(mkdtemp()) self.spiders_dir = self.tmpdir / "test_spiders_xxx" _copytree(orig_spiders_dir, self.spiders_dir) sys.path.append(str(self.tmpdir)) diff --git a/tests/test_spidermiddleware.py b/tests/test_spidermiddleware.py index 38ca8d950..1a80eb7be 100644 --- a/tests/test_spidermiddleware.py +++ b/tests/test_spidermiddleware.py @@ -1,5 +1,6 @@ -import collections.abc -from typing import Optional +from __future__ import annotations + +from collections.abc import AsyncIterator, Iterable from unittest import mock from testfixtures import LogCapture @@ -112,11 +113,11 @@ class BaseAsyncSpiderMiddlewareTestCase(SpiderMiddlewareTestCase): Should work for process_spider_output and, when it's supported, process_start_requests. """ - ITEM_TYPE: type + ITEM_TYPE: type | tuple RESULT_COUNT = 3 # to simplify checks, let everything return 3 objects @staticmethod - def _construct_mw_setting(*mw_classes, start_index: Optional[int] = None): + def _construct_mw_setting(*mw_classes, start_index: int | None = None): if start_index is None: start_index = 10 return {i: c for c, i in enumerate(mw_classes, start=start_index)} @@ -127,7 +128,7 @@ class BaseAsyncSpiderMiddlewareTestCase(SpiderMiddlewareTestCase): yield {"foo": 3} @defer.inlineCallbacks - def _get_middleware_result(self, *mw_classes, start_index: Optional[int] = None): + def _get_middleware_result(self, *mw_classes, start_index: int | None = None): setting = self._construct_mw_setting(*mw_classes, start_index=start_index) self.crawler = get_crawler( Spider, {"SPIDER_MIDDLEWARES_BASE": {}, "SPIDER_MIDDLEWARES": setting} @@ -141,13 +142,13 @@ class BaseAsyncSpiderMiddlewareTestCase(SpiderMiddlewareTestCase): @defer.inlineCallbacks def _test_simple_base( - self, *mw_classes, downgrade: bool = False, start_index: Optional[int] = None + self, *mw_classes, downgrade: bool = False, start_index: int | None = None ): with LogCapture() as log: result = yield self._get_middleware_result( *mw_classes, start_index=start_index ) - self.assertIsInstance(result, collections.abc.Iterable) + self.assertIsInstance(result, Iterable) result_list = list(result) self.assertEqual(len(result_list), self.RESULT_COUNT) self.assertIsInstance(result_list[0], self.ITEM_TYPE) @@ -155,13 +156,13 @@ class BaseAsyncSpiderMiddlewareTestCase(SpiderMiddlewareTestCase): @defer.inlineCallbacks def _test_asyncgen_base( - self, *mw_classes, downgrade: bool = False, start_index: Optional[int] = None + self, *mw_classes, downgrade: bool = False, start_index: int | None = None ): with LogCapture() as log: result = yield self._get_middleware_result( *mw_classes, start_index=start_index ) - self.assertIsInstance(result, collections.abc.AsyncIterator) + self.assertIsInstance(result, AsyncIterator) result_list = yield deferred_from_coro(collect_asyncgen(result)) self.assertEqual(len(result_list), self.RESULT_COUNT) self.assertIsInstance(result_list[0], self.ITEM_TYPE) @@ -328,15 +329,16 @@ class ProcessStartRequestsSimpleMiddleware: class ProcessStartRequestsSimple(BaseAsyncSpiderMiddlewareTestCase): """process_start_requests tests for simple start_requests""" - ITEM_TYPE = Request + ITEM_TYPE = (Request, dict) MW_SIMPLE = ProcessStartRequestsSimpleMiddleware def _start_requests(self): - for i in range(3): + for i in range(2): yield Request(f"https://example.com/{i}", dont_filter=True) + yield {"name": "test item"} @defer.inlineCallbacks - def _get_middleware_result(self, *mw_classes, start_index: Optional[int] = None): + def _get_middleware_result(self, *mw_classes, start_index: int | None = None): setting = self._construct_mw_setting(*mw_classes, start_index=start_index) self.crawler = get_crawler( Spider, {"SPIDER_MIDDLEWARES_BASE": {}, "SPIDER_MIDDLEWARES": setting} @@ -440,7 +442,7 @@ class BuiltinMiddlewareSimpleTest(BaseAsyncSpiderMiddlewareTestCase): MW_UNIVERSAL = ProcessSpiderOutputUniversalMiddleware @defer.inlineCallbacks - def _get_middleware_result(self, *mw_classes, start_index: Optional[int] = None): + def _get_middleware_result(self, *mw_classes, start_index: int | None = None): setting = self._construct_mw_setting(*mw_classes, start_index=start_index) self.crawler = get_crawler(Spider, {"SPIDER_MIDDLEWARES": setting}) self.spider = self.crawler._create_spider("foo") diff --git a/tests/test_spidermiddleware_httperror.py b/tests/test_spidermiddleware_httperror.py index 044455415..01a2b4bb4 100644 --- a/tests/test_spidermiddleware_httperror.py +++ b/tests/test_spidermiddleware_httperror.py @@ -1,5 +1,4 @@ import logging -from typing import Set from unittest import TestCase from testfixtures import LogCapture @@ -17,7 +16,7 @@ from tests.spiders import MockServerSpider class _HttpErrorSpider(MockServerSpider): name = "httperror" - bypass_status_codes: Set[int] = set() + bypass_status_codes: set[int] = set() def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/tests/test_spidermiddleware_offsite.py b/tests/test_spidermiddleware_offsite.py index ea45b7698..837f1c2c8 100644 --- a/tests/test_spidermiddleware_offsite.py +++ b/tests/test_spidermiddleware_offsite.py @@ -16,10 +16,10 @@ class TestOffsiteMiddleware(TestCase): self.mw.spider_opened(self.spider) def _get_spiderargs(self): - return dict( - name="foo", - allowed_domains=["scrapytest.org", "scrapy.org", "scrapy.test.org"], - ) + return { + "name": "foo", + "allowed_domains": ["scrapytest.org", "scrapy.org", "scrapy.test.org"], + } def test_process_spider_output(self): res = Response("http://scrapytest.org") @@ -50,7 +50,7 @@ class TestOffsiteMiddleware(TestCase): class TestOffsiteMiddleware2(TestOffsiteMiddleware): def _get_spiderargs(self): - return dict(name="foo", allowed_domains=None) + return {"name": "foo", "allowed_domains": None} def test_process_spider_output(self): res = Response("http://scrapytest.org") @@ -61,13 +61,16 @@ class TestOffsiteMiddleware2(TestOffsiteMiddleware): class TestOffsiteMiddleware3(TestOffsiteMiddleware2): def _get_spiderargs(self): - return dict(name="foo") + return {"name": "foo"} class TestOffsiteMiddleware4(TestOffsiteMiddleware3): def _get_spiderargs(self): bad_hostname = urlparse("http:////scrapytest.org").hostname - return dict(name="foo", allowed_domains=["scrapytest.org", None, bad_hostname]) + return { + "name": "foo", + "allowed_domains": ["scrapytest.org", None, bad_hostname], + } def test_process_spider_output(self): res = Response("http://scrapytest.org") diff --git a/tests/test_spidermiddleware_referer.py b/tests/test_spidermiddleware_referer.py index afffa87fb..facbaa60d 100644 --- a/tests/test_spidermiddleware_referer.py +++ b/tests/test_spidermiddleware_referer.py @@ -1,5 +1,7 @@ +from __future__ import annotations + import warnings -from typing import Any, Dict, List, Optional, Tuple +from typing import Any from unittest import TestCase from urllib.parse import urlparse @@ -32,10 +34,10 @@ from scrapy.spiders import Spider class TestRefererMiddleware(TestCase): - req_meta: Dict[str, Any] = {} - resp_headers: Dict[str, str] = {} - settings: Dict[str, Any] = {} - scenarii: List[Tuple[str, str, Optional[bytes]]] = [ + req_meta: dict[str, Any] = {} + resp_headers: dict[str, str] = {} + settings: dict[str, Any] = {} + scenarii: list[tuple[str, str, bytes | None]] = [ ("http://scrapytest.org", "http://scrapytest.org/", b"http://scrapytest.org"), ] @@ -65,7 +67,7 @@ class MixinDefault: with some additional filtering of s3:// """ - scenarii: List[Tuple[str, str, Optional[bytes]]] = [ + scenarii: list[tuple[str, str, bytes | None]] = [ ("https://example.com/", "https://scrapy.org/", b"https://example.com/"), ("http://example.com/", "http://scrapy.org/", b"http://example.com/"), ("http://example.com/", "https://scrapy.org/", b"http://example.com/"), @@ -86,7 +88,7 @@ class MixinDefault: class MixinNoReferrer: - scenarii: List[Tuple[str, str, Optional[bytes]]] = [ + scenarii: list[tuple[str, str, bytes | None]] = [ ("https://example.com/page.html", "https://example.com/", None), ("http://www.example.com/", "https://scrapy.org/", None), ("http://www.example.com/", "http://scrapy.org/", None), @@ -96,7 +98,7 @@ class MixinNoReferrer: class MixinNoReferrerWhenDowngrade: - scenarii: List[Tuple[str, str, Optional[bytes]]] = [ + scenarii: list[tuple[str, str, bytes | None]] = [ # TLS to TLS: send non-empty referrer ( "https://example.com/page.html", @@ -178,7 +180,7 @@ class MixinNoReferrerWhenDowngrade: class MixinSameOrigin: - scenarii: List[Tuple[str, str, Optional[bytes]]] = [ + scenarii: list[tuple[str, str, bytes | None]] = [ # Same origin (protocol, host, port): send referrer ( "https://example.com/page.html", @@ -247,7 +249,7 @@ class MixinSameOrigin: class MixinOrigin: - scenarii: List[Tuple[str, str, Optional[bytes]]] = [ + scenarii: list[tuple[str, str, bytes | None]] = [ # TLS or non-TLS to TLS or non-TLS: referrer origin is sent (yes, even for downgrades) ( "https://example.com/page.html", @@ -271,7 +273,7 @@ class MixinOrigin: class MixinStrictOrigin: - scenarii: List[Tuple[str, str, Optional[bytes]]] = [ + scenarii: list[tuple[str, str, bytes | None]] = [ # TLS or non-TLS to TLS or non-TLS: referrer origin is sent but not for downgrades ( "https://example.com/page.html", @@ -299,7 +301,7 @@ class MixinStrictOrigin: class MixinOriginWhenCrossOrigin: - scenarii: List[Tuple[str, str, Optional[bytes]]] = [ + scenarii: list[tuple[str, str, bytes | None]] = [ # Same origin (protocol, host, port): send referrer ( "https://example.com/page.html", @@ -406,7 +408,7 @@ class MixinOriginWhenCrossOrigin: class MixinStrictOriginWhenCrossOrigin: - scenarii: List[Tuple[str, str, Optional[bytes]]] = [ + scenarii: list[tuple[str, str, bytes | None]] = [ # Same origin (protocol, host, port): send referrer ( "https://example.com/page.html", @@ -518,7 +520,7 @@ class MixinStrictOriginWhenCrossOrigin: class MixinUnsafeUrl: - scenarii: List[Tuple[str, str, Optional[bytes]]] = [ + scenarii: list[tuple[str, str, bytes | None]] = [ # TLS to TLS: send referrer ( "https://example.com/sekrit.html", @@ -884,6 +886,53 @@ class TestSettingsPolicyByName(TestCase): with self.assertRaises(RuntimeError): RefererMiddleware(settings) + def test_multiple_policy_tokens(self): + # test parsing without space(s) after the comma + settings1 = Settings( + { + "REFERRER_POLICY": ",".join( + [ + "some-custom-unknown-policy", + POLICY_SAME_ORIGIN, + POLICY_STRICT_ORIGIN_WHEN_CROSS_ORIGIN, + "another-custom-unknown-policy", + ] + ) + } + ) + mw1 = RefererMiddleware(settings1) + self.assertEqual(mw1.default_policy, StrictOriginWhenCrossOriginPolicy) + + # test parsing with space(s) after the comma + settings2 = Settings( + { + "REFERRER_POLICY": ", ".join( + [ + POLICY_STRICT_ORIGIN_WHEN_CROSS_ORIGIN, + "another-custom-unknown-policy", + POLICY_UNSAFE_URL, + ] + ) + } + ) + mw2 = RefererMiddleware(settings2) + self.assertEqual(mw2.default_policy, UnsafeUrlPolicy) + + def test_multiple_policy_tokens_all_invalid(self): + settings = Settings( + { + "REFERRER_POLICY": ",".join( + [ + "some-custom-unknown-policy", + "another-custom-unknown-policy", + "yet-another-custom-unknown-policy", + ] + ) + } + ) + with self.assertRaises(RuntimeError): + RefererMiddleware(settings) + class TestPolicyHeaderPrecedence001(MixinUnsafeUrl, TestRefererMiddleware): settings = {"REFERRER_POLICY": "scrapy.spidermiddlewares.referer.SameOriginPolicy"} @@ -921,8 +970,8 @@ class TestPolicyHeaderPrecedence004( class TestReferrerOnRedirect(TestRefererMiddleware): settings = {"REFERRER_POLICY": "scrapy.spidermiddlewares.referer.UnsafeUrlPolicy"} - scenarii: List[ - Tuple[str, str, Tuple[Tuple[int, str], ...], Optional[bytes], Optional[bytes]] + scenarii: list[ + tuple[str, str, tuple[tuple[int, str], ...], bytes | None, bytes | None] ] = [ # type: ignore[assignment] ( "http://scrapytest.org/1", # parent diff --git a/tests/test_spiderstate.py b/tests/test_spiderstate.py index f97125b76..59d18d92e 100644 --- a/tests/test_spiderstate.py +++ b/tests/test_spiderstate.py @@ -1,6 +1,6 @@ import shutil from datetime import datetime, timezone -from pathlib import Path +from tempfile import mkdtemp from twisted.trial import unittest @@ -12,8 +12,7 @@ from scrapy.utils.test import get_crawler class SpiderStateTest(unittest.TestCase): def test_store_load(self): - jobdir = self.mktemp() - Path(jobdir).mkdir() + jobdir = mkdtemp() try: spider = Spider(name="default") dt = datetime.now(tz=timezone.utc) diff --git a/tests/test_squeues_request.py b/tests/test_squeues_request.py index b444c32b7..499ca46b8 100644 --- a/tests/test_squeues_request.py +++ b/tests/test_squeues_request.py @@ -25,7 +25,7 @@ class BaseQueueTestCase(unittest.TestCase): def setUp(self): self.tmpdir = tempfile.mkdtemp(prefix="scrapy-queue-tests-") self.qpath = self.tempfilename() - self.qdir = self.mkdtemp() + self.qdir = tempfile.mkdtemp() self.crawler = get_crawler(Spider) def tearDown(self): diff --git a/tests/test_utils_datatypes.py b/tests/test_utils_datatypes.py index 9e5f88f48..fb7c90f80 100644 --- a/tests/test_utils_datatypes.py +++ b/tests/test_utils_datatypes.py @@ -1,8 +1,7 @@ import copy import unittest import warnings -from collections.abc import Mapping, MutableMapping -from typing import Iterator +from collections.abc import Iterator, Mapping, MutableMapping from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.http import Request @@ -353,7 +352,7 @@ class LocalWeakReferencedCacheTest(unittest.TestCase): for i, r in enumerate(refs): self.assertIn(r, cache) self.assertEqual(cache[r], i) - del r # delete reference to the last object in the list + del r # delete reference to the last object in the list # pylint: disable=undefined-loop-variable # delete half of the objects, make sure that is reflected in the cache for _ in range(max // 2): diff --git a/tests/test_utils_defer.py b/tests/test_utils_defer.py index a7d54b565..ec0399865 100644 --- a/tests/test_utils_defer.py +++ b/tests/test_utils_defer.py @@ -14,7 +14,6 @@ from scrapy.utils.defer import ( mustbe_deferred, parallel_async, process_chain, - process_chain_both, process_parallel, ) @@ -80,19 +79,6 @@ class DeferUtilsTest(unittest.TestCase): gotexc = True self.assertTrue(gotexc) - @defer.inlineCallbacks - def test_process_chain_both(self): - x = yield process_chain_both( - [cb_fail, cb2, cb3], [None, eb1, None], "res", "v1", "v2" - ) - self.assertEqual(x, "(cb3 (eb1 TypeError v1 v2) v1 v2)") - - fail = Failure(ZeroDivisionError()) - x = yield process_chain_both( - [eb1, cb2, cb3], [eb1, None, None], fail, "v1", "v2" - ) - self.assertEqual(x, "(cb3 (cb2 (eb1 ZeroDivisionError v1 v2) v1 v2) v1 v2)") - @defer.inlineCallbacks def test_process_parallel(self): x = yield process_parallel([cb1, cb2, cb3], "res", "v1", "v2") diff --git a/tests/test_utils_iterators.py b/tests/test_utils_iterators.py index 4a0c34d82..ec377bb19 100644 --- a/tests/test_utils_iterators.py +++ b/tests/test_utils_iterators.py @@ -1,14 +1,14 @@ -from pytest import mark +import pytest from twisted.trial import unittest +from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.http import Response, TextResponse, XmlResponse from scrapy.utils.iterators import _body_or_str, csviter, xmliter, xmliter_lxml from tests import get_testdata -class XmliterTestCase(unittest.TestCase): - xmliter = staticmethod(xmliter) - +class XmliterBaseTestCase: + @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") def test_xmliter(self): body = b""" <?xml version="1.0" encoding="UTF-8"?> @@ -40,6 +40,7 @@ class XmliterTestCase(unittest.TestCase): attrs, [("001", ["Name 1"], ["Type 1"]), ("002", ["Name 2"], ["Type 2"])] ) + @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") def test_xmliter_unusual_node(self): body = b"""<?xml version="1.0" encoding="UTF-8"?> <root> @@ -53,6 +54,7 @@ class XmliterTestCase(unittest.TestCase): ] self.assertEqual(nodenames, [["matchme..."]]) + @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") def test_xmliter_unicode(self): # example taken from https://github.com/scrapy/scrapy/issues/1665 body = """<?xml version="1.0" encoding="UTF-8"?> @@ -112,6 +114,7 @@ class XmliterTestCase(unittest.TestCase): [("26", ["-"], ["80"]), ("21", ["Ab"], ["76"]), ("27", ["A"], ["27"])], ) + @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") def test_xmliter_text(self): body = ( '<?xml version="1.0" encoding="UTF-8"?>' @@ -123,6 +126,7 @@ class XmliterTestCase(unittest.TestCase): [["one"], ["two"]], ) + @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") def test_xmliter_namespaces(self): body = b""" <?xml version="1.0" encoding="UTF-8"?> @@ -162,6 +166,7 @@ class XmliterTestCase(unittest.TestCase): self.assertEqual(node.xpath("id/text()").getall(), []) self.assertEqual(node.xpath("price/text()").getall(), []) + @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") def test_xmliter_namespaced_nodename(self): body = b""" <?xml version="1.0" encoding="UTF-8"?> @@ -190,6 +195,7 @@ class XmliterTestCase(unittest.TestCase): ["http://www.mydummycompany.com/images/item1.jpg"], ) + @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") def test_xmliter_namespaced_nodename_missing(self): body = b""" <?xml version="1.0" encoding="UTF-8"?> @@ -214,6 +220,7 @@ class XmliterTestCase(unittest.TestCase): with self.assertRaises(StopIteration): next(my_iter) + @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") def test_xmliter_exception(self): body = ( '<?xml version="1.0" encoding="UTF-8"?>' @@ -226,10 +233,12 @@ class XmliterTestCase(unittest.TestCase): self.assertRaises(StopIteration, next, iter) + @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") def test_xmliter_objtype_exception(self): i = self.xmliter(42, "product") self.assertRaises(TypeError, next, i) + @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") def test_xmliter_encoding(self): body = ( b'<?xml version="1.0" encoding="ISO-8859-9"?>\n' @@ -244,12 +253,25 @@ class XmliterTestCase(unittest.TestCase): ) -class LxmlXmliterTestCase(XmliterTestCase): - xmliter = staticmethod(xmliter_lxml) +class XmliterTestCase(XmliterBaseTestCase, unittest.TestCase): + xmliter = staticmethod(xmliter) - @mark.xfail(reason="known bug of the current implementation") - def test_xmliter_namespaced_nodename(self): - super().test_xmliter_namespaced_nodename() + def test_deprecation(self): + body = b""" + <?xml version="1.0" encoding="UTF-8"?> + <products> + <product></product> + </products> + """ + with pytest.warns( + ScrapyDeprecationWarning, + match="xmliter", + ): + next(self.xmliter(body, "product")) + + +class LxmlXmliterTestCase(XmliterBaseTestCase, unittest.TestCase): + xmliter = staticmethod(xmliter_lxml) def test_xmliter_iterate_namespace(self): body = b""" @@ -333,7 +355,7 @@ class UtilsCsvTestCase(unittest.TestCase): response = TextResponse(url="http://example.com/", body=body) csv = csviter(response) - result = [row for row in csv] + result = list(csv) self.assertEqual( result, [ @@ -355,7 +377,7 @@ class UtilsCsvTestCase(unittest.TestCase): csv = csviter(response, delimiter="\t") self.assertEqual( - [row for row in csv], + list(csv), [ {"id": "1", "name": "alpha", "value": "foobar"}, {"id": "2", "name": "unicode", "value": "\xfan\xedc\xf3d\xe9\u203d"}, @@ -372,7 +394,7 @@ class UtilsCsvTestCase(unittest.TestCase): csv1 = csviter(response1, quotechar="'") self.assertEqual( - [row for row in csv1], + list(csv1), [ {"id": "1", "name": "alpha", "value": "foobar"}, {"id": "2", "name": "unicode", "value": "\xfan\xedc\xf3d\xe9\u203d"}, @@ -385,7 +407,7 @@ class UtilsCsvTestCase(unittest.TestCase): csv2 = csviter(response2, delimiter="|", quotechar="'") self.assertEqual( - [row for row in csv2], + list(csv2), [ {"id": "1", "name": "alpha", "value": "foobar"}, {"id": "2", "name": "unicode", "value": "\xfan\xedc\xf3d\xe9\u203d"}, @@ -400,7 +422,7 @@ class UtilsCsvTestCase(unittest.TestCase): csv = csviter(response) self.assertEqual( - [row for row in csv], + list(csv), [ {"'id'": "1", "'name'": "'alpha'", "'value'": "'foobar'"}, { @@ -419,7 +441,7 @@ class UtilsCsvTestCase(unittest.TestCase): csv = csviter(response, delimiter="\t") self.assertEqual( - [row for row in csv], + list(csv), [ {"id": "1", "name": "alpha", "value": "foobar"}, {"id": "2", "name": "unicode", "value": "\xfan\xedc\xf3d\xe9\u203d"}, @@ -436,7 +458,7 @@ class UtilsCsvTestCase(unittest.TestCase): csv = csviter(response, headers=[h.decode("utf-8") for h in headers]) self.assertEqual( - [row for row in csv], + list(csv), [ {"id": "1", "name": "alpha", "value": "foobar"}, {"id": "2", "name": "unicode", "value": "\xfan\xedc\xf3d\xe9\u203d"}, @@ -453,7 +475,7 @@ class UtilsCsvTestCase(unittest.TestCase): csv = csviter(response) self.assertEqual( - [row for row in csv], + list(csv), [ {"id": "1", "name": "alpha", "value": "foobar"}, {"id": "2", "name": "unicode", "value": "\xfan\xedc\xf3d\xe9\u203d"}, diff --git a/tests/test_utils_log.py b/tests/test_utils_log.py index eae744df5..76820eabf 100644 --- a/tests/test_utils_log.py +++ b/tests/test_utils_log.py @@ -1,18 +1,30 @@ +from __future__ import annotations + +import json import logging +import re import sys import unittest +from io import StringIO +from typing import TYPE_CHECKING, Any +from unittest import TestCase +import pytest from testfixtures import LogCapture from twisted.python.failure import Failure -from scrapy.extensions import telnet from scrapy.utils.log import ( LogCounterHandler, + SpiderLoggerAdapter, StreamLogger, TopLevelFormatter, failure_to_exc_info, ) from scrapy.utils.test import get_crawler +from tests.spiders import LogSpider + +if TYPE_CHECKING: + from collections.abc import Mapping, MutableMapping class FailureToExcInfoTest(unittest.TestCase): @@ -62,9 +74,6 @@ class TopLevelFormatterTest(unittest.TestCase): class LogCounterHandlerTest(unittest.TestCase): def setUp(self): settings = {"LOG_LEVEL": "WARNING"} - if not telnet.TWISTED_CONCH_AVAILABLE: - # disable it to avoid the extra warning - settings["TELNETCONSOLE_ENABLED"] = False self.logger = logging.getLogger("test") self.logger.setLevel(logging.NOTSET) self.logger.propagate = False @@ -106,3 +115,180 @@ class StreamLoggerTest(unittest.TestCase): with LogCapture() as log: print("test log msg") log.check(("test", "ERROR", "test log msg")) + + +@pytest.mark.parametrize( + ("base_extra", "log_extra", "expected_extra"), + ( + ( + {"spider": "test"}, + {"extra": {"log_extra": "info"}}, + {"extra": {"log_extra": "info", "spider": "test"}}, + ), + ( + {"spider": "test"}, + {"extra": None}, + {"extra": {"spider": "test"}}, + ), + ( + {"spider": "test"}, + {"extra": {"spider": "test2"}}, + {"extra": {"spider": "test"}}, + ), + ), +) +def test_spider_logger_adapter_process( + base_extra: Mapping[str, Any], log_extra: MutableMapping, expected_extra: dict +): + logger = logging.getLogger("test") + spider_logger_adapter = SpiderLoggerAdapter(logger, base_extra) + + log_message = "test_log_message" + result_message, result_kwargs = spider_logger_adapter.process( + log_message, log_extra + ) + + assert result_message == log_message + assert result_kwargs == expected_extra + + +class LoggingTestCase(TestCase): + def setUp(self): + self.log_stream = StringIO() + handler = logging.StreamHandler(self.log_stream) + logger = logging.getLogger("log_spider") + logger.addHandler(handler) + logger.setLevel(logging.DEBUG) + self.handler = handler + self.logger = logger + self.spider = LogSpider() + + def tearDown(self): + self.logger.removeHandler(self.handler) + + def test_debug_logging(self): + log_message = "Foo message" + self.spider.log_debug(log_message) + log_contents = self.log_stream.getvalue() + + assert log_contents == f"{log_message}\n" + + def test_info_logging(self): + log_message = "Bar message" + self.spider.log_info(log_message) + log_contents = self.log_stream.getvalue() + + assert log_contents == f"{log_message}\n" + + def test_warning_logging(self): + log_message = "Baz message" + self.spider.log_warning(log_message) + log_contents = self.log_stream.getvalue() + + assert log_contents == f"{log_message}\n" + + def test_error_logging(self): + log_message = "Foo bar message" + self.spider.log_error(log_message) + log_contents = self.log_stream.getvalue() + + assert log_contents == f"{log_message}\n" + + def test_critical_logging(self): + log_message = "Foo bar baz message" + self.spider.log_critical(log_message) + log_contents = self.log_stream.getvalue() + + assert log_contents == f"{log_message}\n" + + +class LoggingWithExtraTestCase(TestCase): + def setUp(self): + self.log_stream = StringIO() + handler = logging.StreamHandler(self.log_stream) + formatter = logging.Formatter( + '{"levelname": "%(levelname)s", "message": "%(message)s", "spider": "%(spider)s", "important_info": "%(important_info)s"}' + ) + handler.setFormatter(formatter) + logger = logging.getLogger("log_spider") + logger.addHandler(handler) + logger.setLevel(logging.DEBUG) + self.handler = handler + self.logger = logger + self.spider = LogSpider() + self.regex_pattern = re.compile(r"^<LogSpider\s'log_spider'\sat\s[^>]+>$") + + def tearDown(self): + self.logger.removeHandler(self.handler) + + def test_debug_logging(self): + log_message = "Foo message" + extra = {"important_info": "foo"} + self.spider.log_debug(log_message, extra) + log_contents = self.log_stream.getvalue() + log_contents = json.loads(log_contents) + + assert log_contents["levelname"] == "DEBUG" + assert log_contents["message"] == log_message + assert self.regex_pattern.match(log_contents["spider"]) + assert log_contents["important_info"] == extra["important_info"] + + def test_info_logging(self): + log_message = "Bar message" + extra = {"important_info": "bar"} + self.spider.log_info(log_message, extra) + log_contents = self.log_stream.getvalue() + log_contents = json.loads(log_contents) + + assert log_contents["levelname"] == "INFO" + assert log_contents["message"] == log_message + assert self.regex_pattern.match(log_contents["spider"]) + assert log_contents["important_info"] == extra["important_info"] + + def test_warning_logging(self): + log_message = "Baz message" + extra = {"important_info": "baz"} + self.spider.log_warning(log_message, extra) + log_contents = self.log_stream.getvalue() + log_contents = json.loads(log_contents) + + assert log_contents["levelname"] == "WARNING" + assert log_contents["message"] == log_message + assert self.regex_pattern.match(log_contents["spider"]) + assert log_contents["important_info"] == extra["important_info"] + + def test_error_logging(self): + log_message = "Foo bar message" + extra = {"important_info": "foo bar"} + self.spider.log_error(log_message, extra) + log_contents = self.log_stream.getvalue() + log_contents = json.loads(log_contents) + + assert log_contents["levelname"] == "ERROR" + assert log_contents["message"] == log_message + assert self.regex_pattern.match(log_contents["spider"]) + assert log_contents["important_info"] == extra["important_info"] + + def test_critical_logging(self): + log_message = "Foo bar baz message" + extra = {"important_info": "foo bar baz"} + self.spider.log_critical(log_message, extra) + log_contents = self.log_stream.getvalue() + log_contents = json.loads(log_contents) + + assert log_contents["levelname"] == "CRITICAL" + assert log_contents["message"] == log_message + assert self.regex_pattern.match(log_contents["spider"]) + assert log_contents["important_info"] == extra["important_info"] + + def test_overwrite_spider_extra(self): + log_message = "Foo message" + extra = {"important_info": "foo", "spider": "shouldn't change"} + self.spider.log_error(log_message, extra) + log_contents = self.log_stream.getvalue() + log_contents = json.loads(log_contents) + + assert log_contents["levelname"] == "ERROR" + assert log_contents["message"] == log_message + assert self.regex_pattern.match(log_contents["spider"]) + assert log_contents["important_info"] == extra["important_info"] diff --git a/tests/test_utils_project.py b/tests/test_utils_project.py index 90bd350a5..3831f4c21 100644 --- a/tests/test_utils_project.py +++ b/tests/test_utils_project.py @@ -6,6 +6,7 @@ import unittest import warnings from pathlib import Path +from scrapy.utils.misc import set_environ from scrapy.utils.project import data_path, get_project_settings @@ -38,20 +39,6 @@ class ProjectUtilsTest(unittest.TestCase): self.assertEqual(abspath, data_path(abspath)) -@contextlib.contextmanager -def set_env(**update): - modified = set(update.keys()) & set(os.environ.keys()) - update_after = {k: os.environ[k] for k in modified} - remove_after = frozenset(k for k in update if k not in os.environ) - try: - os.environ.update(update) - yield - finally: - os.environ.update(update_after) - for k in remove_after: - os.environ.pop(k) - - class GetProjectSettingsTestCase(unittest.TestCase): def test_valid_envvar(self): value = "tests.test_cmdline.settings" @@ -60,7 +47,7 @@ class GetProjectSettingsTestCase(unittest.TestCase): } with warnings.catch_warnings(): warnings.simplefilter("error") - with set_env(**envvars): + with set_environ(**envvars): settings = get_project_settings() assert settings.get("SETTINGS_MODULE") == value @@ -69,7 +56,7 @@ class GetProjectSettingsTestCase(unittest.TestCase): envvars = { "SCRAPY_FOO": "bar", } - with set_env(**envvars): + with set_environ(**envvars): settings = get_project_settings() assert settings.get("SCRAPY_FOO") is None @@ -80,7 +67,7 @@ class GetProjectSettingsTestCase(unittest.TestCase): "SCRAPY_FOO": "bar", "SCRAPY_SETTINGS_MODULE": value, } - with set_env(**envvars): + with set_environ(**envvars): settings = get_project_settings() assert settings.get("SETTINGS_MODULE") == value assert settings.get("SCRAPY_FOO") is None diff --git a/tests/test_utils_python.py b/tests/test_utils_python.py index 80d2e8da1..4c60deafe 100644 --- a/tests/test_utils_python.py +++ b/tests/test_utils_python.py @@ -175,7 +175,7 @@ class UtilsPythonTestCase(unittest.TestCase): self.assertTrue(equal_attributes(a, b, ["x", "y"])) a.y = 1 - # differente attributes + # different attributes self.assertFalse(equal_attributes(a, b, ["x", "y"])) # test callable @@ -239,8 +239,11 @@ class UtilsPythonTestCase(unittest.TestCase): self.assertEqual(get_func_args(" ".join, stripself=True), ["iterable"]) if platform.python_implementation() == "CPython": - # doesn't work on CPython: https://bugs.python.org/issue42785 - self.assertEqual(get_func_args(operator.itemgetter(2)), []) + # This didn't work on older versions of CPython: https://github.com/python/cpython/issues/86951 + self.assertIn( + get_func_args(operator.itemgetter(2), stripself=True), + [[], ["args", "kwargs"]], + ) elif platform.python_implementation() == "PyPy": self.assertEqual( get_func_args(operator.itemgetter(2), stripself=True), ["obj"] diff --git a/tests/test_utils_request.py b/tests/test_utils_request.py index 633077eec..7156b13d0 100644 --- a/tests/test_utils_request.py +++ b/tests/test_utils_request.py @@ -1,8 +1,9 @@ +from __future__ import annotations + import json import unittest import warnings from hashlib import sha1 -from typing import Dict, Optional, Tuple, Union from weakref import WeakKeyDictionary from scrapy.http import Request @@ -56,12 +57,12 @@ class FingerprintTest(unittest.TestCase): maxDiff = None function: staticmethod = staticmethod(fingerprint) - cache: Union[ - "WeakKeyDictionary[Request, Dict[Tuple[Optional[Tuple[bytes, ...]], bool], bytes]]", - "WeakKeyDictionary[Request, Dict[Tuple[Optional[Tuple[bytes, ...]], bool], str]]", - ] = _fingerprint_cache + cache: ( + WeakKeyDictionary[Request, dict[tuple[tuple[bytes, ...] | None, bool], bytes]] + | WeakKeyDictionary[Request, dict[tuple[tuple[bytes, ...] | None, bool], str]] + ) = _fingerprint_cache default_cache_key = (None, False) - known_hashes: Tuple[Tuple[Request, Union[bytes, str], Dict], ...] = ( + known_hashes: tuple[tuple[Request, bytes | str, dict], ...] = ( ( Request("http://example.org"), b"xs\xd7\x0c3uj\x15\xfe\xd7d\x9b\xa9\t\xe0d\xbf\x9cXD", diff --git a/tests/test_utils_response.py b/tests/test_utils_response.py index 661fb47a3..7ad86127b 100644 --- a/tests/test_utils_response.py +++ b/tests/test_utils_response.py @@ -1,10 +1,14 @@ import unittest from pathlib import Path +from time import process_time from urllib.parse import urlparse +import pytest + from scrapy.http import HtmlResponse, Response, TextResponse from scrapy.utils.python import to_bytes from scrapy.utils.response import ( + _remove_html_comments, get_base_url, get_meta_refresh, open_in_browser, @@ -166,3 +170,76 @@ class ResponseUtilsTest(unittest.TestCase): assert open_in_browser( r5, _openfunc=check_base_url ), "Inject unique base url with conditional comment" + + def test_open_in_browser_redos_comment(self): + MAX_CPU_TIME = 0.02 + + # Exploit input from + # https://makenowjust-labs.github.io/recheck/playground/ + # for /<!--.*?-->/ (old pattern to remove comments). + body = b"-><!--\x00" * 25_000 + b"->\n<!---->" + + response = HtmlResponse("https://example.com", body=body) + + start_time = process_time() + + open_in_browser(response, lambda url: True) + + end_time = process_time() + self.assertLess(end_time - start_time, MAX_CPU_TIME) + + def test_open_in_browser_redos_head(self): + MAX_CPU_TIME = 0.02 + + # Exploit input from + # https://makenowjust-labs.github.io/recheck/playground/ + # for /(<head(?:>|\s.*?>))/ (old pattern to find the head element). + body = b"<head\t" * 8_000 + + response = HtmlResponse("https://example.com", body=body) + + start_time = process_time() + + open_in_browser(response, lambda url: True) + + end_time = process_time() + self.assertLess(end_time - start_time, MAX_CPU_TIME) + + +@pytest.mark.parametrize( + "input_body,output_body", + ( + ( + b"a<!--", + b"a", + ), + ( + b"a<!---->b", + b"ab", + ), + ( + b"a<!--b-->c", + b"ac", + ), + ( + b"a<!--b-->c<!--", + b"ac", + ), + ( + b"a<!--b-->c<!--d", + b"ac", + ), + ( + b"a<!--b-->c<!---->d", + b"acd", + ), + ( + b"a<!--b--><!--c-->d", + b"ad", + ), + ), +) +def test_remove_html_comments(input_body, output_body): + assert ( + _remove_html_comments(input_body) == output_body + ), f"{_remove_html_comments(input_body)=} == {output_body=}" diff --git a/tests/test_utils_signal.py b/tests/test_utils_signal.py index 65b99e0c4..60232f10b 100644 --- a/tests/test_utils_signal.py +++ b/tests/test_utils_signal.py @@ -75,9 +75,6 @@ class SendCatchLogDeferredAsyncDefTest(SendCatchLogDeferredTest): await defer.succeed(42) return "OK" - def test_send_catch_log(self): - return super().test_send_catch_log() - @mark.only_asyncio() class SendCatchLogDeferredAsyncioTest(SendCatchLogDeferredTest): @@ -87,9 +84,6 @@ class SendCatchLogDeferredAsyncioTest(SendCatchLogDeferredTest): await asyncio.sleep(0.2) return await get_from_asyncio_queue("OK") - def test_send_catch_log(self): - return super().test_send_catch_log() - class SendCatchLogTest2(unittest.TestCase): def test_error_logged_if_deferred_not_supported(self): diff --git a/tests/test_utils_spider.py b/tests/test_utils_spider.py index 460ae40c3..dd1d26448 100644 --- a/tests/test_utils_spider.py +++ b/tests/test_utils_spider.py @@ -26,7 +26,7 @@ class UtilsSpidersTestCase(unittest.TestCase): self.assertEqual(list(iterate_spider_output([r, i, o])), [r, i, o]) def test_iter_spider_classes(self): - import tests.test_utils_spider + import tests.test_utils_spider # pylint: disable=import-self it = iter_spider_classes(tests.test_utils_spider) self.assertEqual(set(it), {MySpider1, MySpider2}) diff --git a/tests/test_utils_template.py b/tests/test_utils_template.py index cbe80e157..fc42c0d2f 100644 --- a/tests/test_utils_template.py +++ b/tests/test_utils_template.py @@ -16,7 +16,7 @@ class UtilsRenderTemplateFileTestCase(unittest.TestCase): rmtree(self.tmp_path) def test_simple_render(self): - context = dict(project_name="proj", name="spi", classname="TheSpider") + context = {"project_name": "proj", "name": "spi", "classname": "TheSpider"} template = "from ${project_name}.spiders.${name} import ${classname}" rendered = "from proj.spiders.spi import TheSpider" diff --git a/tests/test_webclient.py b/tests/test_webclient.py index d4b6ba15b..cce119001 100644 --- a/tests/test_webclient.py +++ b/tests/test_webclient.py @@ -2,8 +2,10 @@ from twisted.internet import defer Tests borrowed from the twisted.web.client tests. """ + import shutil from pathlib import Path +from tempfile import mkdtemp import OpenSSL.SSL from twisted.internet import defer, reactor @@ -274,8 +276,7 @@ class WebClientTestCase(unittest.TestCase): return reactor.listenTCP(0, site, interface="127.0.0.1") def setUp(self): - self.tmpname = Path(self.mktemp()) - self.tmpname.mkdir() + 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")) @@ -440,8 +441,7 @@ class WebClientSSLTestCase(unittest.TestCase): return f"https://127.0.0.1:{self.portno}/{path}" def setUp(self): - self.tmpname = Path(self.mktemp()) - self.tmpname.mkdir() + self.tmpname = Path(mkdtemp()) (self.tmpname / "file").write_bytes(b"0123456789") r = static.File(str(self.tmpname)) r.putChild(b"payload", PayloadResource()) diff --git a/tests_typing/test_http_request.mypy-testing b/tests_typing/test_http_request.mypy-testing new file mode 100644 index 000000000..3926c830f --- /dev/null +++ b/tests_typing/test_http_request.mypy-testing @@ -0,0 +1,80 @@ +from typing import Any, Dict + +import pytest + +from scrapy import Request +from scrapy.http import JsonRequest + + +class MyRequest(Request): + pass + + +class MyRequest2(Request): + pass + + +@pytest.mark.mypy_testing +def mypy_test_headers(): + Request("data:,", headers=1) # E: Argument "headers" to "Request" has incompatible type "int"; expected "Union[Mapping[str, Any], Iterable[tuple[str, Any]], None]" + Request("data:,", headers=None) + Request("data:,", headers={}) + Request("data:,", headers=[]) + Request("data:,", headers={"foo": "bar"}) + Request("data:,", headers={b"foo": "bar"}) + Request("data:,", headers={"foo": b"bar"}) + Request("data:,", headers=[("foo", "bar")]) + Request("data:,", headers=[(b"foo", "bar")]) + Request("data:,", headers=[("foo", b"bar")]) + + +@pytest.mark.mypy_testing +def mypy_test_copy(): + req = Request("data:,") + reveal_type(req) # R: scrapy.http.request.Request + req_copy = req.copy() + reveal_type(req_copy) # R: scrapy.http.request.Request + + +@pytest.mark.mypy_testing +def mypy_test_copy_subclass(): + req = MyRequest("data:,") + reveal_type(req) # R: __main__.MyRequest + req_copy = req.copy() + reveal_type(req_copy) # R: __main__.MyRequest + + +@pytest.mark.mypy_testing +def mypy_test_replace(): + req = Request("data:,") + reveal_type(req) # R: scrapy.http.request.Request + req_copy = req.replace(body=b"a") + reveal_type(req_copy) # R: scrapy.http.request.Request + kwargs: Dict[str, Any] = {} + req_copy2 = req.replace(body=b"a", **kwargs) + reveal_type(req_copy2) # R: Any + + +@pytest.mark.mypy_testing +def mypy_test_replace_subclass(): + req = MyRequest("data:,") + reveal_type(req) # R: __main__.MyRequest + req_copy = req.replace(body=b"a") + reveal_type(req_copy) # R: __main__.MyRequest + req_copy2 = req.replace(body=b"a", cls=MyRequest2) + reveal_type(req_copy2) # R: __main__.MyRequest2 + kwargs: Dict[str, Any] = {} + req_copy3 = req.replace(body=b"a", cls=MyRequest2, **kwargs) + reveal_type(req_copy3) # R: __main__.MyRequest2 + + +@pytest.mark.mypy_testing +def mypy_test_jsonrequest_copy_replace(): + req = JsonRequest("data:,") + reveal_type(req) # R: scrapy.http.request.json_request.JsonRequest + req_copy = req.copy() + reveal_type(req_copy) # R: scrapy.http.request.json_request.JsonRequest + req_copy = req.replace(body=b"a") + reveal_type(req_copy) # R: scrapy.http.request.json_request.JsonRequest + req_copy_my = req.replace(body=b"a", cls=MyRequest) + reveal_type(req_copy_my) # R: __main__.MyRequest diff --git a/tests_typing/test_http_response.mypy-testing b/tests_typing/test_http_response.mypy-testing new file mode 100644 index 000000000..88aedbd3e --- /dev/null +++ b/tests_typing/test_http_response.mypy-testing @@ -0,0 +1,59 @@ +from typing import Any, Dict + +import pytest + +from scrapy.http import HtmlResponse, Response, TextResponse + + +@pytest.mark.mypy_testing +def mypy_test_headers(): + Response("data:,", headers=1) # E: Argument "headers" to "Response" has incompatible type "int"; expected "Union[Mapping[str, Any], Iterable[tuple[str, Any]], None]" + Response("data:,", headers=None) + Response("data:,", headers={}) + Response("data:,", headers=[]) + Response("data:,", headers={"foo": "bar"}) + Response("data:,", headers={b"foo": "bar"}) + Response("data:,", headers={"foo": b"bar"}) + Response("data:,", headers=[("foo", "bar")]) + Response("data:,", headers=[(b"foo", "bar")]) + Response("data:,", headers=[("foo", b"bar")]) + + +@pytest.mark.mypy_testing +def mypy_test_copy(): + resp = Response("data:,") + reveal_type(resp) # R: scrapy.http.response.Response + resp_copy = resp.copy() + reveal_type(resp_copy) # R: scrapy.http.response.Response + + +@pytest.mark.mypy_testing +def mypy_test_copy_subclass(): + resp = HtmlResponse("data:,") + reveal_type(resp) # R: scrapy.http.response.html.HtmlResponse + resp_copy = resp.copy() + reveal_type(resp_copy) # R: scrapy.http.response.html.HtmlResponse + + +@pytest.mark.mypy_testing +def mypy_test_replace(): + resp = Response("data:,") + reveal_type(resp) # R: scrapy.http.response.Response + resp_copy = resp.replace(body=b"a") + reveal_type(resp_copy) # R: scrapy.http.response.Response + kwargs: Dict[str, Any] = {} + resp_copy2 = resp.replace(body=b"a", **kwargs) + reveal_type(resp_copy2) # R: Any + + +@pytest.mark.mypy_testing +def mypy_test_replace_subclass(): + resp = HtmlResponse("data:,") + reveal_type(resp) # R: scrapy.http.response.html.HtmlResponse + resp_copy = resp.replace(body=b"a") + reveal_type(resp_copy) # R: scrapy.http.response.html.HtmlResponse + resp_copy2 = resp.replace(body=b"a", cls=TextResponse) + reveal_type(resp_copy2) # R: scrapy.http.response.text.TextResponse + kwargs: Dict[str, Any] = {} + resp_copy3 = resp.replace(body=b"a", cls=TextResponse, **kwargs) + reveal_type(resp_copy3) # R: scrapy.http.response.text.TextResponse diff --git a/tests_typing/test_spiders.mypy-testing b/tests_typing/test_spiders.mypy-testing new file mode 100644 index 000000000..162e31d0c --- /dev/null +++ b/tests_typing/test_spiders.mypy-testing @@ -0,0 +1,68 @@ +from typing import Any + +import pytest + +from scrapy.http import HtmlResponse, Response +from scrapy.spiders import Spider + + +class SimpleSpider(Spider): + pass + + +class SameOverrideSpider(Spider): + def parse(self, response: Response, **kwargs: Any) -> Any: + pass + + +class NoKwargsSpider(Spider): + def parse(self, response: Response) -> Any: + pass + + +class SpecificKwargsSpider(Spider): + def parse(self, response: Response, page: int) -> Any: + pass + + +class NarrowOverrideSpider(Spider): + # without type: ignore this produces several note lines in addition to an error line, + # which is unsupported by pytest-mypy-testing + def parse(self, response: HtmlResponse, **kwargs: Any) -> Any: # type: ignore[override] + pass + + +@pytest.mark.mypy_testing +def test_spider_parse() -> None: + spider = Spider() + reveal_type(spider.parse) # R: def (scrapy.http.response.Response, *Any, **Any) -> Any + + +@pytest.mark.mypy_testing +def test_spider_parse_override_simple() -> None: + spider = SimpleSpider() + reveal_type(spider.parse) # R: def (scrapy.http.response.Response, *Any, **Any) -> Any + + +@pytest.mark.mypy_testing +def test_spider_parse_override_same() -> None: + spider = SameOverrideSpider() + reveal_type(spider.parse) # R: def (response: scrapy.http.response.Response, **kwargs: Any) -> Any + + +@pytest.mark.mypy_testing +def test_spider_parse_override_no_kwargs() -> None: + spider = NoKwargsSpider() + reveal_type(spider.parse) # R: def (response: scrapy.http.response.Response) -> Any + + +@pytest.mark.mypy_testing +def test_spider_parse_override_specific_kwargs() -> None: + spider = SpecificKwargsSpider() + reveal_type(spider.parse) # R: def (response: scrapy.http.response.Response, page: builtins.int) -> Any + + +@pytest.mark.mypy_testing +def test_spider_parse_override_narrow() -> None: + spider = NarrowOverrideSpider() + reveal_type(spider.parse) # R: def (response: scrapy.http.response.html.HtmlResponse, **kwargs: Any) -> Any diff --git a/tox.ini b/tox.ini index d9dcacc01..fbbce48d4 100644 --- a/tox.ini +++ b/tox.ini @@ -7,9 +7,23 @@ envlist = pre-commit,pylint,typing,py minversion = 1.7.0 +[test-requirements] +deps = + attrs + pexpect >= 4.8.0 + pyftpdlib >= 1.5.8 + pygments + pytest + pytest-cov==4.0.0 + pytest-xdist + sybil >= 1.3.0 # https://github.com/cjw296/sybil/issues/20#issuecomment-605433422 + testfixtures + pywin32; sys_platform == "win32" + [testenv] deps = - -rtests/requirements.txt + {[test-requirements]deps} + # mitmproxy does not support PyPy mitmproxy; implementation_name != 'pypy' passenv = @@ -29,19 +43,29 @@ install_command = [testenv:typing] basepython = python3 deps = - mypy==1.6.1 - typing-extensions==4.8.0 - types-attrs==19.1.0 - types-lxml==2023.10.21 - types-Pillow==10.1.0.0 - types-Pygments==2.16.0.0 - types-pyOpenSSL==23.3.0.0 - types-setuptools==68.2.0.0 - # 2.1.2 fixes a typing bug: https://github.com/scrapy/w3lib/pull/211 - w3lib >= 2.1.2 + mypy==1.12.0 + typing-extensions==4.12.2 + types-lxml==2024.9.16 + types-Pygments==2.18.0.20240506 + botocore-stubs==1.35.39 + boto3-stubs[s3]==1.35.39 + attrs >= 18.2.0 + Pillow >= 10.3.0 + pyOpenSSL >= 24.2.1 + pytest >= 8.2.0 + w3lib >= 2.2.0 commands = mypy {posargs: scrapy tests} +[testenv:typing-tests] +basepython = python3.9 +deps = + {[test-requirements]deps} + {[testenv:typing]deps} + pytest-mypy-testing==0.1.3 +commands = + pytest {posargs: tests_typing} + [testenv:pre-commit] basepython = python3 deps = @@ -53,35 +77,36 @@ commands = basepython = python3 deps = {[testenv:extra-deps]deps} - pylint==3.0.1 + pylint==3.2.5 commands = pylint conftest.py docs extras scrapy setup.py tests [testenv:twinecheck] basepython = python3 deps = - twine==4.0.2 - build==1.0.3 + twine==5.1.1 + build==1.2.1 commands = python -m build --sdist twine check dist/* [pinned] +basepython = python3.9 deps = - cryptography==36.0.0 + cryptography==37.0.0 cssselect==0.9.1 h2==3.0 itemadapter==0.1.0 parsel==1.5.0 Protego==0.1.15 - pyOpenSSL==21.0.0 + pyOpenSSL==22.0.0 queuelib==1.4.2 service_identity==18.1.0 - Twisted[http2]==18.9.0 + Twisted[http2]==21.7.0 w3lib==1.17.0 zope.interface==5.1.0 - lxml==4.4.1 - -rtests/requirements.txt + lxml==4.6.0 + {[test-requirements]deps} # mitmproxy 8.0.0 requires upgrading some of the pinned dependencies # above, hence we do not install it in pinned environments at the moment @@ -93,7 +118,7 @@ commands = pytest --cov=scrapy --cov-report=xml --cov-report= {posargs:--durations=10 scrapy tests} [testenv:pinned] -basepython = python3.8 +basepython = {[pinned]basepython} deps = {[pinned]deps} PyDispatcher==2.0.5 @@ -103,7 +128,7 @@ setenv = commands = {[pinned]commands} [testenv:windows-pinned] -basepython = python3 +basepython = {[pinned]basepython} deps = {[pinned]deps} PyDispatcher==2.0.5 @@ -118,21 +143,31 @@ deps = {[testenv]deps} boto3 google-cloud-storage - # Twisted[http2] currently forces old mitmproxy because of h2 version - # restrictions in their deps, so we need to pin old markupsafe here too. - markupsafe < 2.1.0 robotexclusionrulesparser Pillow Twisted[http2] + uvloop; platform_system != "Windows" + bpython # optional for shell wrapper tests + brotli; implementation_name != 'pypy' # optional for HTTP compress downloader middleware tests + brotlicffi; implementation_name == 'pypy' # optional for HTTP compress downloader middleware tests + zstandard; implementation_name != 'pypy' # optional for HTTP compress downloader middleware tests + ipython [testenv:extra-deps-pinned] -basepython = python3.8 +basepython = {[pinned]basepython} deps = {[pinned]deps} boto3==1.20.0 google-cloud-storage==1.29.0 Pillow==7.1.0 robotexclusionrulesparser==1.6.2 + brotlipy + uvloop==0.14.0; platform_system != "Windows" + bpython==0.7.1 + zstandard==0.1; implementation_name != 'pypy' + ipython==2.0.0 + brotli==0.5.2; implementation_name != 'pypy' + brotlicffi==0.8.0; implementation_name == 'pypy' install_command = {[pinned]install_command} setenv = {[pinned]setenv} @@ -143,6 +178,7 @@ commands = {[testenv]commands} --reactor=asyncio [testenv:asyncio-pinned] +basepython = {[pinned]basepython} deps = {[testenv:pinned]deps} commands = {[pinned]commands} --reactor=asyncio install_command = {[pinned]install_command} @@ -155,12 +191,12 @@ commands = pytest {posargs:--durations=10 docs scrapy tests} [testenv:pypy3-pinned] -basepython = {[testenv:pypy3]basepython} +basepython = pypy3.9 deps = {[pinned]deps} PyPyDispatcher==2.1.0 commands = - pytest --durations=10 scrapy tests + pytest {posargs:--durations=10 scrapy tests} install_command = {[pinned]install_command} setenv = {[pinned]setenv} @@ -208,7 +244,7 @@ commands = pytest --cov=scrapy --cov-report=xml --cov-report= {posargs:tests -k s3} [testenv:botocore-pinned] -basepython = python3.8 +basepython = {[pinned]basepython} deps = {[pinned]deps} botocore==1.4.87